diff --git a/scheduler.module b/scheduler.module
index f64d3a8..107f342 100644
--- a/scheduler.module
+++ b/scheduler.module
@@ -14,6 +14,8 @@ use Drupal\Core\Form\FormStateInterface;
 use Drupal\Core\Url;
 use Drupal\node\NodeInterface;
 use Drupal\Core\Routing\RouteMatchInterface;
+use Drupal\scheduler\SchedulerEvent;
+use Drupal\scheduler\SchedulerEvents;
 
 // @todo Move constants into a class, so we do not pollute the global space,
 // and use them like this, for example: SchedulerApi::MY_CONSTANT.
@@ -310,8 +312,12 @@ function scheduler_node_presave(EntityInterface $node) {
       }
       $node->publish_on->value = NULL;
       $node->setPublished(TRUE);
-      // Allow modules to react to immediate publishing.
-      _scheduler_scheduler_api($node, 'publish_immediately');
+
+      // Trigger the PUBLISH_IMMEDIATELY event so that modules can react after
+      // the node has been published.
+      $event = new SchedulerEvent($node);
+      \Drupal::service('event_dispatcher')->dispatch(SchedulerEvents::PUBLISH_IMMEDIATELY, $event);
+      $node = $event->getNode();
     }
     else {
       // Ensure the node is unpublished as it will be published by cron later.
@@ -409,28 +415,6 @@ function scheduler_cron_is_running() {
 }
 
 /**
- * Scheduler API to perform actions when nodes are (un)published.
- *
- * This allows other modules to implement hook_scheduler_api($node, $action).
- *
- * @param \Drupal\node\NodeInterface $node
- *   The node object.
- * @param string $action
- *   The action being performed, either 'pre_publish', 'publish',
- *   'publish_immediately', 'pre_unpublish' or 'unpublish'.
- *
- * @deprecated Will be moved to a service.
- *
- * @see https://www.drupal.org/node/2651338
- */
-function _scheduler_scheduler_api(NodeInterface $node, $action) {
-  foreach (\Drupal::moduleHandler()->getImplementations('scheduler_api') as $module) {
-    $function = $module . '_scheduler_api';
-    $function($node, $action);
-  }
-}
-
-/**
  * Implements hook_entity_extra_field_info().
  */
 function scheduler_entity_extra_field_info() {
diff --git a/src/SchedulerEvent.php b/src/SchedulerEvent.php
new file mode 100644
index 0000000..f8970d6
--- /dev/null
+++ b/src/SchedulerEvent.php
@@ -0,0 +1,54 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\scheduler\SchedulerEvent.
+ */
+
+namespace Drupal\scheduler;
+
+use Drupal\Core\Entity\EntityInterface;
+use Symfony\Component\EventDispatcher\Event;
+
+/**
+ * Wraps a scheduler event for event listeners.
+ */
+class SchedulerEvent extends Event {
+
+  /**
+   * Node object.
+   *
+   * @var EntityInterface $node
+   */
+  protected $node;
+
+  /**
+   * Constructs a scheduler event object.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface
+   *   Node object.
+   */
+  public function __construct(EntityInterface $node) {
+    $this->node = $node;
+  }
+
+  /**
+   * Gets node object.
+   *
+   * @return \Drupal\Core\Entity\EntityInterface
+   *   The node object that caused the event to fire.
+   */
+  public function getNode() {
+    return $this->node;
+  }
+
+  /**
+   * Sets the node object.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $node
+   */
+  public function setNode(EntityInterface $node) {
+    $this->node = $node;
+  }
+
+}
diff --git a/src/SchedulerEvents.php b/src/SchedulerEvents.php
new file mode 100644
index 0000000..a7a07a6
--- /dev/null
+++ b/src/SchedulerEvents.php
@@ -0,0 +1,88 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\scheduler\SchedulerEvents.
+ */
+
+namespace Drupal\scheduler;
+
+/**
+ * Contains all events dispatched by Scheduler.
+ */
+final class SchedulerEvents {
+
+  /**
+   * The event triggered after a node is published immediately.
+   *
+   * This event allows modules to react to a node being published immediately.
+   * The event listener method receives a \Drupal\Core\Entity\EntityInterface
+   * instance.
+   *
+   * @Event
+   *
+   * @see \Drupal\scheduler\SchedulerEvent
+   *
+   * @var string
+   */
+  const PUBLISH_IMMEDIATELY = 'scheduler.publish_immediately';
+
+  /**
+   * The event triggered after a node is published via cron.
+   *
+   * This event allows modules to react to a node being published. The event
+   * listener method receives a \Drupal\Core\Entity\EntityInterface instance.
+   *
+   * @Event
+   *
+   * @see \Drupal\scheduler\SchedulerEvent
+   *
+   * @var string
+   */
+  const PUBLISH = 'scheduler.publish';
+
+  /**
+   * The event triggered before a node is published via cron.
+   *
+   * This event allows modules to react before a node is published. The event
+   * listener method receives a \Drupal\Core\Entity\EntityInterface
+   * instance.
+   *
+   * @Event
+   *
+   * @see \Drupal\scheduler\SchedulerEvent
+   *
+   * @var string
+   */
+  const PRE_PUBLISH = 'scheduler.pre_publish';
+
+  /**
+   * The event triggered before a node is unpublished via cron.
+   *
+   * This event allows modules to react before a node is unpublished. The
+   * event listener method receives a \Drupal\Core\Entity\EntityInterface
+   * instance.
+   *
+   * @Event
+   *
+   * @see \Drupal\scheduler\SchedulerEvent
+   *
+   * @var string
+   */
+  const PRE_UNPUBLISH = 'scheduler.pre_unpublish';
+
+  /**
+   * The event triggered after a node is unpublished via cron.
+   *
+   * This event allows modules to react to a node being unpublished. The event
+   * listener method receives a \Drupal\Core\Entity\EntityInterface instance.
+   *
+   * @Event
+   *
+   * @see \Drupal\scheduler\SchedulerEvent
+   *
+   * @var string
+   */
+  const UNPUBLISH = 'scheduler.unpublish';
+
+}
diff --git a/src/SchedulerManager.php b/src/SchedulerManager.php
index 457b559..71eee8e 100644
--- a/src/SchedulerManager.php
+++ b/src/SchedulerManager.php
@@ -79,6 +79,9 @@ class SchedulerManager {
    * @throws \Drupal\scheduler\Exception\SchedulerNodeTypeNotEnabledException
    */
   public function publish() {
+    /** @var \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher */
+    $dispatcher = \Drupal::service('event_dispatcher');
+
     $result = FALSE;
 
     // If the time now is greater than the time to publish a node, publish it.
@@ -111,8 +114,11 @@ class SchedulerManager {
         continue;
       }
 
-      // Invoke Scheduler API for modules to react before the node is published.
-      _scheduler_scheduler_api($node, 'pre_' . $action);
+      // Trigger the PRE_PUBLISH event so that modules can react before the node
+      // is published.
+      $event = new SchedulerEvent($node);
+      $dispatcher->dispatch(SchedulerEvents::PRE_PUBLISH, $event);
+      $node = $event->getNode();
 
       // If an API call has removed the date $node->set('changed', $publish_on)
       // would fail, so trap this exception here and give a meaningful message.
@@ -167,8 +173,11 @@ class SchedulerManager {
         */
       }
 
-      // Invoke scheduler API for modules to react after the node is published.
-      _scheduler_scheduler_api($node, $action);
+      // Trigger the PUBLISH event so that modules can react after the node is
+      // published.
+      $event = new SchedulerEvent($node);
+      $dispatcher->dispatch(SchedulerEvents::PUBLISH, $event);
+      $event->getNode()->save();
 
       $result = TRUE;
     }
@@ -186,6 +195,9 @@ class SchedulerManager {
    * @throws \Drupal\scheduler\Exception\SchedulerNodeTypeNotEnabledException
    */
   public function unpublish() {
+    /** @var \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher */
+    $dispatcher =  \Drupal::service('event_dispatcher');
+
     $result = FALSE;
 
     // If the time is greater than the time to unpublish a node, unpublish it.
@@ -227,8 +239,11 @@ class SchedulerManager {
         continue;
       }
 
-      // Invoke scheduler API for modules to react before the node is unpublished.
-      _scheduler_scheduler_api($node, 'pre_' . $action);
+      // Trigger the PRE_UNPUBLISH event so that modules can react before the
+      // node is unpublished.
+      $event = new SchedulerEvent($node);
+      $dispatcher->dispatch(SchedulerEvents::PRE_UNPUBLISH, $event);
+      $node = $event->getNode();
 
       // If an API call has removed the date $node->set('changed', $unpublish_on)
       // would fail, so trap this exception here and give a meaningful message.
@@ -280,8 +295,11 @@ class SchedulerManager {
         */
       }
 
-      // Invoke scheduler API for modules to react after the node is unpublished.
-      _scheduler_scheduler_api($node, $action);
+      // Trigger the UNPUBLISH event so that modules can react before the node
+      // is unpublished.
+      $event = new SchedulerEvent($node);
+      $dispatcher->dispatch(SchedulerEvents::UNPUBLISH, $event);
+      $event->getNode()->save();
 
       $result = TRUE;
     }
diff --git a/tests/modules/scheduler_api_test.services.yml b/tests/modules/scheduler_api_test.services.yml
new file mode 100644
index 0000000..582bcd5
--- /dev/null
+++ b/tests/modules/scheduler_api_test.services.yml
@@ -0,0 +1,5 @@
+services:
+  scheduler_api_test.event_subscriber:
+    class: Drupal\scheduler_api_test\EventSubscriber
+    tags:
+      - { name: event_subscriber }
diff --git a/tests/modules/src/EventSubscriber.php b/tests/modules/src/EventSubscriber.php
new file mode 100644
index 0000000..80e9d1f
--- /dev/null
+++ b/tests/modules/src/EventSubscriber.php
@@ -0,0 +1,127 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\scheduler_api_test\EventSubscriber.
+ */
+
+namespace Drupal\scheduler_api_test;
+
+use Drupal\node\Entity\Node;
+use Drupal\scheduler\SchedulerEvent;
+use Drupal\scheduler\SchedulerEvents;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+
+/**
+ * Tests events fired on entity objects.
+ *
+ * These events allow modules to react to the Scheduler process being performed.
+ * They are all triggered during Scheduler cron processing with the exception of
+ * 'publish_immediately' which is triggered from scheduler_node_presave().
+ *
+ * The tests use the standard 'sticky' and 'promote' fields as a simple way to
+ * check the processing. Use extra conditional checks on $node->isPublished() to
+ * make the tests stronger so they fail if the calls are in the wrong place.
+ *
+ * To allow this API test module to be enabled interactively (for development
+ * and testing) we must avoid unwanted side-effects on other non-test nodes.
+ * This is done simply by checking that the node title starts with 'API TEST'.
+ *
+ * @group scheduler_api_test
+ */
+class EventSubscriber implements EventSubscriberInterface {
+
+  /**
+   * Operations to perform before Scheduler publishes a node.
+   *
+   * @param \Drupal\scheduler\SchedulerEvent $event
+   */
+  public function prePublish(SchedulerEvent $event) {
+    /** @var Node $node */
+    $node = $event->getNode();
+    // Before publishing a node make it sticky.
+    if (!$node->isPublished() && strpos($node->title->value, 'API TEST') === 0) {
+      $node->setSticky(TRUE);
+      $event->setNode($node);
+    }
+  }
+
+  /**
+   * Operations to perform before Scheduler unpublishes a node.
+   *
+   * @param \Drupal\scheduler\SchedulerEvent $event
+   */
+  public function preUnpublish(SchedulerEvent $event) {
+    /** @var Node $node */
+    $node = $event->getNode();
+    if ($node->isPublished() && strpos($node->title->value, 'API TEST') === 0) {
+      // Before unpublishing a node make it unsticky.
+      $node->setSticky(FALSE);
+      $event->setNode($node);
+    }
+  }
+
+  /**
+   * Operations to perform after Scheduler publishes a node.
+   *
+   * @param \Drupal\scheduler\SchedulerEvent $event
+   */
+  public function publish(SchedulerEvent $event) {
+    /** @var Node $node */
+    $node = $event->getNode();
+    // After publishing a node promote it to the front page.
+    if ($node->isPublished() && strpos($node->title->value, 'API TEST') === 0) {
+      $node->setPromoted(TRUE);
+      $event->setNode($node);
+    }
+  }
+
+  /**
+   * Operations to perform after Scheduler unpublishes a node.
+   *
+   * @param \Drupal\scheduler\SchedulerEvent $event
+   */
+  public function unpublish(SchedulerEvent $event) {
+    /** @var Node $node */
+    $node = $event->getNode();
+    // After unpublishing a node remove it from the front page.
+    if (!$node->isPublished() && strpos($node->title->value, 'API TEST') === 0) {
+      $node->setPromoted(FALSE);
+      $event->setNode($node);
+    }
+  }
+
+  /**
+   * Operations to perform after Scheduler publishes a node immediately not via
+   * cron.
+   *
+   * @param \Drupal\scheduler\SchedulerEvent $event
+   */
+  public function publishImmediately(SchedulerEvent $event) {
+    /** @var Node $node */
+    $node = $event->getNode();
+    // When publishing immediately set the node to sticky and promoted, and
+    // also change the title.
+    if (!$node->isPromoted() && strpos($node->title->value, 'API TEST') === 0) {
+      $node->setTitle('Published immediately')
+        ->setPromoted(TRUE)
+        ->setSticky(TRUE);
+      $event->setNode($node);
+    }
+  }
+
+
+  /**
+   * {@inheritdoc}
+   */
+  static function getSubscribedEvents() {
+    // The values in the arrays give the function names above.
+    $events[SchedulerEvents::PRE_UNPUBLISH][] = array('preUnpublish');
+    $events[SchedulerEvents::PRE_PUBLISH][] = array('prePublish');
+    $events[SchedulerEvents::PUBLISH][] = array('publish');
+    $events[SchedulerEvents::PUBLISH_IMMEDIATELY][] = array('publishImmediately');
+    $events[SchedulerEvents::UNPUBLISH][] = array('unpublish');
+    return $events;
+  }
+
+}
diff --git a/tests/modules/scheduler_api_test.module b/tests/modules/scheduler_api_test.module
index 6637168..40586a9 100644
--- a/tests/modules/scheduler_api_test.module
+++ b/tests/modules/scheduler_api_test.module
@@ -8,49 +8,6 @@
 use Drupal\node\Entity\Node;
 
 /**
- * Implements hook_scheduler_api().
- */
-function scheduler_api_test_scheduler_api($node, $action) {
-  // This hook allows modules to react to the Scheduler process being performed.
-  // It is invoked during cron processing and also from scheduler_node_presave().
-
-  if (strpos($node->title->value, 'API TEST') === FALSE) {
-    // Only process nodes where the title starts 'API TEST' to avoid unwanted
-    // side-effects on non-test nodes when this module is enabled interactively.
-    return;
-  }
-
-  // Use the standard 'sticky' and 'promote' fields as a simple way to test the
-  // processing. Use conditional checks on $node->isPublished() to make the
-  // tests stronger so that they will fail if the calls are in the wrong place.
-  switch ($action) {
-    case 'pre_publish' :
-      // Before publishing a node make it sticky.
-      if (!$node->isPublished()) $node->set('sticky', TRUE)->save();
-      break;
-    case 'publish' :
-      // After publishing a node promote to front page.
-      if ($node->isPublished()) $node->set('promote', TRUE)->save();
-      break;
-    case 'publish_immediately' :
-      // When publishing immediately set the node to sticky and promoted, and
-      // also change the title. Do not use ->save() as this breaks the system!
-      $node->set('sticky', TRUE)->set('promote', TRUE);
-      $node->title->value = 'Published immediately';
-      break;
-    case 'pre_unpublish' :
-      // Before unpublishing a node make it unsticky.
-      if ($node->isPublished()) $node->set('sticky', FALSE)->save();
-      break;
-    case 'unpublish' :
-      // After unpublishing a node remove it from front page.
-      if (!$node->isPublished()) $node->set('promote', FALSE)->save();
-      break;
-    default:
-  }
-}
-
-/**
  * Implements hook_scheduler_nid_list().
  */
 function scheduler_api_test_scheduler_nid_list($action) {
diff --git a/scheduler.api.php b/scheduler.api.php
index 8da2d1e..d657e92 100644
--- a/scheduler.api.php
+++ b/scheduler.api.php
@@ -11,35 +11,6 @@
  */
 
 /**
- * Modules can implement hook_scheduler_api() to react to the Scheduler
- * operation being done on a node. The hook is invoked during cron processing
- * and also from scheduler_node_presave().
- *
- * @param \Drupal\node\NodeInterface $node
- *   The scheduled node object that is being processed.
- *
- * @param string $action
- *   $action determines what is being done to the node. The value will be
- *   'pre_publish', 'publish', 'publish_immediately', 'pre_unpublish'
- *   or 'unpublish'.
- */
-function hook_scheduler_api($node, $action) {
-  switch ($action) {
-    case 'pre_publish' :
-      break;
-    case 'publish' :
-      break;
-    case 'publish_immediately' :
-      break;
-    case 'pre_unpublish' :
-      break;
-    case 'unpublish' :
-      break;
-    default:
-  }
-}
-
-/**
  * Modules can implement hook_scheduler_nid_list() to add more node ids into the
  * list to be processed in the current cron run. This hook is invoked during
  * cron runs only.
