diff --git a/examples.module b/examples.module
index 1333b6e..5d5fbd8 100644
--- a/examples.module
+++ b/examples.module
@@ -47,6 +47,7 @@ function examples_toolbar() {
     'field_example' => 'field_example.description',
     'field_permission_example' => 'field_permission_example.description',
     'file_example' => 'file_example.fileapi',
+    'filter_example' => 'filter_example.page',
     'hooks_example' => 'hooks_example.description',
     'js_example' => 'js_example.info',
     'node_type_example' => 'config_node_type_example.description',
diff --git a/filter_example/filter_example.info.yml b/filter_example/filter_example.info.yml
new file mode 100644
index 0000000..77b4f48
--- /dev/null
+++ b/filter_example/filter_example.info.yml
@@ -0,0 +1,9 @@
+name: 'Filter example'
+type: module
+description: 'An example module showing how to define a custom filter.'
+package: 'Example modules'
+core: 8.x
+dependencies:
+  - drupal:node
+  - drupal:filter
+  - examples:examples
diff --git a/filter_example/filter_example.links.menu.yml b/filter_example/filter_example.links.menu.yml
new file mode 100644
index 0000000..217e725
--- /dev/null
+++ b/filter_example/filter_example.links.menu.yml
@@ -0,0 +1,4 @@
+filter_example.page:
+  title: 'Filter Example'
+  route_name: 'filter_example.page'
+  expanded: 'TRUE'
diff --git a/filter_example/filter_example.module b/filter_example/filter_example.module
new file mode 100644
index 0000000..7141478
--- /dev/null
+++ b/filter_example/filter_example.module
@@ -0,0 +1,35 @@
+<?php
+
+/**
+ * @file
+ * @defgroup filter_example Example: Filter
+ * @ingroup examples
+ * @{
+ * Demonstrates the creation of filters.
+ *
+ * This is an example outlining how a module can be used to define a filter
+ * to be run on user-submitted content before it is output to the browser.
+ *
+ * To show all the capabilities of the filter system, we will define two filters
+ * in this module. One will substitute the string "foo" with an
+ * administratively-defined replacement string. The other will find a custom
+ * XML tag, <time />, and replace it by the current time.
+ *
+ * Foo filter
+ *
+ * Drupal has several content formats (they are not filters), and in our example
+ * the foo replacement can be configured for each one of them, allowing an html
+ * or php replacement, so the module includes a settings callback, with options
+ * to configure that replacements. Also, a Tips callback will help showing the
+ * current replacement for the content type being edited.
+ *
+ * Time filter.
+ *
+ * This filter is a little trickier to implement than the previous one.
+ * Since the input involves special HTML characters (< and >) we have to
+ * run the filter before HTML is escaped/stripped by other filters. But
+ * we want to use HTML in our result as well, and so if we run this filter
+ * first our replacement string could be escaped or stripped. The solution
+ * is to use the "prepare" operation to escape the special characters, and
+ * to later replace our escaped version in the "process" step.
+ */
diff --git a/filter_example/filter_example.routing.yml b/filter_example/filter_example.routing.yml
new file mode 100644
index 0000000..812dd17
--- /dev/null
+++ b/filter_example/filter_example.routing.yml
@@ -0,0 +1,7 @@
+filter_example.page:
+  path: 'examples/filter-example'
+  defaults:
+    _controller: '\Drupal\filter_example\Controller\FilterExampleController::description'
+    _title: 'Filter Example'
+  requirements:
+    _permission: 'access content'
diff --git a/filter_example/src/Controller/FilterExampleController.php b/filter_example/src/Controller/FilterExampleController.php
new file mode 100644
index 0000000..37442f2
--- /dev/null
+++ b/filter_example/src/Controller/FilterExampleController.php
@@ -0,0 +1,22 @@
+<?php
+
+namespace Drupal\filter_example\Controller;
+
+use Drupal\Core\Controller\ControllerBase;
+use Drupal\examples\Utility\DescriptionTemplateTrait;
+
+/**
+ * Controller routines for Filter example routes.
+ */
+class FilterExampleController extends ControllerBase {
+
+  use DescriptionTemplateTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getModuleName() {
+    return 'filter_example';
+  }
+
+}
diff --git a/filter_example/src/Plugin/Filter/FilterFoo.php b/filter_example/src/Plugin/Filter/FilterFoo.php
new file mode 100644
index 0000000..f679a4f
--- /dev/null
+++ b/filter_example/src/Plugin/Filter/FilterFoo.php
@@ -0,0 +1,84 @@
+<?php
+
+namespace Drupal\filter_example\Plugin\Filter;
+
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\filter\FilterProcessResult;
+use Drupal\filter\Plugin\FilterBase;
+
+/**
+ * Foo filter.
+ *
+ * Drupal has several text formats (they are not filters), and in our example
+ * the foo replacement can be configured for each one of them, so the module
+ * includes a settingsForm method, with options to configure those replacements.
+ * Also, a Tips method will help showing the current replacement
+ * for the content type being edited.
+ *
+ * @Filter(
+ *   id = "filter_foo",
+ *   title = @Translation("Foo Filter (example)"),
+ *   description = @Translation("Every instance of 'foo' in the input text will be replaced with a preconfigured replacement."),
+ *   type = Drupal\filter\Plugin\FilterInterface::TYPE_TRANSFORM_REVERSIBLE,
+ *   settings = {
+ *     "filter_example_foo" = "bar"
+ *   }
+ * )
+ */
+class FilterFoo extends FilterBase {
+
+  /**
+   * Settings for foo filter.
+   *
+   * Make use of settings to have different replacements for every input format.
+   * Since we allow the administrator to define the string that gets substituted
+   * when "foo" is encountered, we need to provide an interface for this kind of
+   * customization.
+   *
+   * The settings defined in this form are stored in database by the filter
+   * module, and they will be available in the $this->settings.
+   */
+  public function settingsForm(array $form, FormStateInterface $form_state) {
+    $form['filter_example_foo'] = [
+      '#type' => 'textfield',
+      '#title' => $this->t('Substitution string'),
+      '#default_value' => $this->settings['filter_example_foo'],
+      '#description' => $this->t('The string to substitute for "foo" everywhere in the text.'),
+    ];
+    return $form;
+  }
+
+  /**
+   * Foo filter process callback.
+   *
+   * The actual filtering is performed here. The supplied text should be
+   * returned once any necessary substitutions have taken place.
+   * The example just replaces foo with our custom defined string in
+   * the settings page.
+   */
+  public function process($text, $langcode) {
+    $replace = $this->settings['filter_example_foo'];
+    $new_text = str_replace('foo', $replace, $text);
+    return new FilterProcessResult($new_text);
+  }
+
+  /**
+   * Filter tips callback for foo filter.
+   *
+   * The tips callback allows filters to provide help text to users during the
+   * content editing process. Short tips are provided on the content editing
+   * screen, while long tips are provided on a separate linked page. Short tips
+   * are optional, but long tips are highly recommended.
+   */
+  public function tips($long = FALSE) {
+    $replacement = $this->settings['filter_example_foo'];
+    if (!$long) {
+      // This string will be shown in the content add/edit form.
+      return $this->t('%foo replaced with %replacement.', ['%foo' => 'foo', '%replacement' => $replacement]);
+    }
+    else {
+      return $this->t('Every instance of "foo" in the input text will be replaced with a configurable value. You can configure this value and put whatever you want there. The replacement value is "%replacement".', ['%replacement' => $replacement]);
+    }
+  }
+
+}
diff --git a/filter_example/src/Plugin/Filter/FilterTime.php b/filter_example/src/Plugin/Filter/FilterTime.php
new file mode 100644
index 0000000..edfaa75
--- /dev/null
+++ b/filter_example/src/Plugin/Filter/FilterTime.php
@@ -0,0 +1,56 @@
+<?php
+
+namespace Drupal\filter_example\Plugin\Filter;
+
+use Drupal\filter\Plugin\FilterBase;
+use Drupal\filter\FilterProcessResult;
+
+/**
+ * Provides a filter to replace "foo".
+ *
+ * When used in combination with the filter_align filter, this must run last.
+ *
+ * @Filter(
+ *   id = "filter_time",
+ *   title = @Translation("Time Tag (example)"),
+ *   description = @Translation("Every instance of the special &lt;time /&gt; tag will be replaced with the current date and time in the user's specified time zone."),
+ *   type = Drupal\filter\Plugin\FilterInterface::TYPE_MARKUP_LANGUAGE,
+ * )
+ */
+class FilterTime extends FilterBase {
+
+  /**
+   * Time filter prepare callback.
+   *
+   * We'll use [filter-example-time] as a replacement for the time tag.
+   * Note that in a more complicated filter a closing tag may also be
+   * required.
+   */
+  public function prepare($text, $langcode) {
+    return preg_replace('!<time ?/>!', '[filter-example-time]', $text);
+  }
+
+  /**
+   * Time filter process callback.
+   *
+   * Now, in the "process" step, we'll search for our escaped time tags and
+   * do the real filtering: replace the xml tag with the date.
+   */
+  public function process($text, $langcode) {
+    $new_text = str_replace('[filter-example-time]', '<em>' . format_date(time()) . '</em>', $text);
+    return new FilterProcessResult($new_text);
+  }
+
+  /**
+   * Filter tips callback for time filter.
+   *
+   * The tips callback allows filters to provide help text to users during the
+   * content editing process. Short tips are provided on the content editing
+   * screen, while long tips are provided on a separate linked page. Short tips
+   * are optional, but long tips are highly recommended.
+   */
+  public function tips($long = FALSE) {
+    return $this->t('<em>&lt;time /&gt;</em> is replaced with the current time.');
+  }
+
+}
diff --git a/filter_example/templates/description.html.twig b/filter_example/templates/description.html.twig
new file mode 100644
index 0000000..a759efe
--- /dev/null
+++ b/filter_example/templates/description.html.twig
@@ -0,0 +1,15 @@
+{#
+/**
+ * @file
+ * Contains the text of the filter_example explanation page
+ */
+#}
+
+{% set filter_admin_overview = path('filter.admin_overview') %}
+
+{% trans %}
+<p>This example provides two filters.</p>
+<p>Foo Filter replaces "foo" with a configurable replacement.</p>
+<p>Time Tag replaces the string "&lt;time /&gt;" with the current time.</p>
+<p>To use these filters, go to <a href="{{ filter_admin_overview }}">admin/config/content/formats</a> and configure an input format, or create a new one.</p>
+{% endtrans %}
diff --git a/filter_example/tests/src/Functional/FilterExampleTest.php b/filter_example/tests/src/Functional/FilterExampleTest.php
new file mode 100644
index 0000000..dfd8e34
--- /dev/null
+++ b/filter_example/tests/src/Functional/FilterExampleTest.php
@@ -0,0 +1,105 @@
+<?php
+
+namespace Drupal\Tests\filter_example\Functional;
+
+use Drupal\filter\Entity\FilterFormat;
+use Drupal\Tests\BrowserTestBase;
+
+/**
+ * Test the functionality of the Filter Example module.
+ *
+ * @ingroup filter_example
+ * @group examples
+ */
+class FilterExampleTest extends BrowserTestBase {
+
+  /**
+   * Modules to enable.
+   *
+   * @var array
+   */
+  public static $modules = ['filter_example'];
+
+  /**
+   * A user with administrative permissions.
+   *
+   * @var \Drupal\user\UserInterface
+   */
+  protected $adminUser;
+  protected $filteredHtml;
+  protected $fullHtml;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+    $this->filteredHtml = FilterFormat::load('filtered_html');
+    $this->fullHtml = FilterFormat::load('full_html');
+    // Create users.
+    $this->adminUser = $this->drupalCreateUser([
+      'administer filters',
+      $this->filteredHtml->getPermissionName(),
+      $this->fullHtml->getPermissionName(),
+      'bypass node access',
+    ]);
+  }
+
+  /**
+   * Functional test of the foo filter.
+   *
+   * Login user, create an example node, and test filter functionality through
+   * the admin user interfaces.
+   */
+  public function testFilterExampleBasic() {
+    $this->drupalLogin($this->adminUser);
+    $web_assert = $this->assertSession();
+
+    $edit = [
+      'filters[filter_time][status]' => TRUE,
+      'filters[filter_foo][status]' => TRUE,
+    ];
+    $this->drupalPostForm('admin/config/content/formats/manage/' . $this->filteredHtml->id(), $edit, t('Save configuration'));
+    $web_assert->pageTextContains(t('The text format ' . $this->filteredHtml->label() . ' has been updated.'));
+
+    $content_type = $this->drupalCreateContentType();
+
+    $edit = [];
+    $edit['title[0][value]'] = $this->randomMachineName();
+    $edit['body[0][value]'] = 'What foo is it? it is <time />';
+    $edit['body[0][format]'] = $this->filteredHtml->id();
+
+    $this->drupalPostForm('node/add/' . $content_type->id(), $edit, t('Save'));
+    $web_assert->statusCodeEquals(200);
+    $time = \Drupal::service('date.formatter')->format(time());
+    $node = $this->drupalGetNodeByTitle($edit['title[0][value]'], TRUE);
+
+    $web_assert->pageTextContains(t('What bar is it? it is ' . $time));
+    $this->drupalGet('node/' . $node->id() . '/edit');
+
+    // Enable foo filter in other format id 2.
+    $edit = [
+      'filters[filter_foo][status]' => TRUE,
+    ];
+    $this->drupalPostForm('admin/config/content/formats/manage/' . $this->fullHtml->id(), $edit, t('Save configuration'));
+    $web_assert->pageTextContains(t('The text format '. $this->fullHtml->label() .' has been updated.'));
+
+    $replacement = $this->randomMachineName();
+    $options = [
+      'filters[filter_foo][settings][filter_example_foo]' => $replacement,
+    ];
+    $this->drupalPostForm('admin/config/content/formats/manage/' . $this->fullHtml->id(), $options, t('Save configuration'));
+    $web_assert->pageTextContains(t('The text format '. $this->fullHtml->label() .' has been updated.'));
+    $edit = [];
+    $edit['title[0][value]'] = $this->randomMachineName();
+    $edit['body[0][value]'] = 'What foo is it? it is <time />';
+    $edit['body[0][format]'] = $this->fullHtml->id();
+
+    $this->drupalPostForm('node/add/' . $content_type->id(), $edit, t('Save'));
+    $web_assert->statusCodeEquals(200);
+
+    $web_assert->pageTextContains('What ' . $replacement . ' is it');
+
+  }
+
+}
