diff --git a/mod/follow.php b/mod/follow.php
deleted file mode 100644
index cd39d4dda7..0000000000
--- a/mod/follow.php
+++ /dev/null
@@ -1,210 +0,0 @@
-<?php
-/**
- * @copyright Copyright (C) 2010-2022, the Friendica project
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program.  If not, see <https://www.gnu.org/licenses/>.
- *
- */
-
-use Friendica\App;
-use Friendica\Content\Widget;
-use Friendica\Core\Protocol;
-use Friendica\Core\Renderer;
-use Friendica\DI;
-use Friendica\Model\Contact;
-use Friendica\Model\Profile;
-use Friendica\Model\Item;
-use Friendica\Network\Probe;
-use Friendica\Database\DBA;
-use Friendica\Model\Post;
-use Friendica\Model\User;
-use Friendica\Util\Strings;
-
-function follow_post(App $a)
-{
-	if (!DI::userSession()->getLocalUserId()) {
-		throw new \Friendica\Network\HTTPException\ForbiddenException(DI::l10n()->t('Access denied.'));
-	}
-
-	if (isset($_REQUEST['cancel'])) {
-		DI::baseUrl()->redirect('contact');
-	}
-
-	$url = Probe::cleanURI($_REQUEST['url']);
-
-	follow_process($a, $url);
-}
-
-function follow_content(App $a)
-{
-	$return_path = 'contact';
-
-	if (!DI::userSession()->getLocalUserId()) {
-		DI::sysmsg()->addNotice(DI::l10n()->t('Permission denied.'));
-		DI::baseUrl()->redirect($return_path);
-		// NOTREACHED
-	}
-
-	$uid = DI::userSession()->getLocalUserId();
-
-	$url = Probe::cleanURI(trim($_REQUEST['url'] ?? ''));
-
-	// Issue 6874: Allow remote following from Peertube
-	if (strpos($url, 'acct:') === 0) {
-		$url = str_replace('acct:', '', $url);
-	}
-
-	if (!$url) {
-		DI::baseUrl()->redirect($return_path);
-	}
-
-	$submit = DI::l10n()->t('Submit Request');
-
-	// Don't try to add a pending contact
-	$user_contact = DBA::selectFirst('contact', ['pending'], ["`uid` = ? AND ((`rel` != ?) OR (`network` = ?)) AND
-		(`nurl` = ? OR `alias` = ? OR `alias` = ?) AND `network` != ?",
-		$uid, Contact::FOLLOWER, Protocol::DFRN, Strings::normaliseLink($url),
-		Strings::normaliseLink($url), $url, Protocol::STATUSNET]);
-
-	if (DBA::isResult($user_contact)) {
-		if ($user_contact['pending']) {
-			DI::sysmsg()->addNotice(DI::l10n()->t('You already added this contact.'));
-			$submit = '';
-		}
-	}
-
-	$contact = Contact::getByURL($url, true);
-
-	// Possibly it is a mail contact
-	if (empty($contact)) {
-		$contact = Probe::uri($url, Protocol::MAIL, $uid);
-	}
-
-	if (empty($contact) || ($contact['network'] == Protocol::PHANTOM)) {
-		// Possibly it is a remote item and not an account
-		follow_remote_item($url);
-
-		DI::sysmsg()->addNotice(DI::l10n()->t("The network type couldn't be detected. Contact can't be added."));
-		$submit = '';
-		$contact = ['url' => $url, 'network' => Protocol::PHANTOM, 'name' => $url, 'keywords' => ''];
-	}
-
-	$protocol = Contact::getProtocol($contact['url'], $contact['network']);
-
-	if (($protocol == Protocol::DIASPORA) && !DI::config()->get('system', 'diaspora_enabled')) {
-		DI::sysmsg()->addNotice(DI::l10n()->t("Diaspora support isn't enabled. Contact can't be added."));
-		$submit = '';
-	}
-
-	if (($protocol == Protocol::OSTATUS) && DI::config()->get('system', 'ostatus_disabled')) {
-		DI::sysmsg()->addNotice(DI::l10n()->t("OStatus support is disabled. Contact can't be added."));
-		$submit = '';
-	}
-
-	if ($protocol == Protocol::MAIL) {
-		$contact['url'] = $contact['addr'];
-	}
-
-	if (!empty($_REQUEST['auto'])) {
-		follow_process($a, $contact['url']);
-	}
-
-	$request = DI::baseUrl() . '/follow';
-	$tpl = Renderer::getMarkupTemplate('auto_request.tpl');
-
-	$owner = User::getOwnerDataById($uid);
-	if (empty($owner)) {
-		DI::sysmsg()->addNotice(DI::l10n()->t('Permission denied.'));
-		DI::baseUrl()->redirect($return_path);
-		// NOTREACHED
-	}
-
-	$myaddr = $owner['url'];
-
-	$o = Renderer::replaceMacros($tpl, [
-		'$header'        => DI::l10n()->t('Connect/Follow'),
-		'$pls_answer'    => DI::l10n()->t('Please answer the following:'),
-		'$your_address'  => DI::l10n()->t('Your Identity Address:'),
-		'$url_label'     => DI::l10n()->t('Profile URL'),
-		'$keywords_label'=> DI::l10n()->t('Tags:'),
-		'$submit'        => $submit,
-		'$cancel'        => DI::l10n()->t('Cancel'),
-
-		'$action'        => $request,
-		'$name'          => $contact['name'],
-		'$url'           => $contact['url'],
-		'$zrl'           => Profile::zrl($contact['url']),
-		'$myaddr'        => $myaddr,
-		'$keywords'      => $contact['keywords'],
-
-		'$does_know_you' => ['knowyou', DI::l10n()->t('%s knows you', $contact['name'])],
-		'$addnote_field' => ['dfrn-request-message', DI::l10n()->t('Add a personal note:')],
-	]);
-
-	DI::page()['aside'] = '';
-
-	if (!in_array($protocol, [Protocol::PHANTOM, Protocol::MAIL])) {
-		DI::page()['aside'] = Widget\VCard::getHTML($contact);
-
-		$o .= Renderer::replaceMacros(Renderer::getMarkupTemplate('section_title.tpl'),
-			['$title' => DI::l10n()->t('Status Messages and Posts')]
-		);
-
-		// Show last public posts
-		$o .= Contact::getPostsFromUrl($contact['url']);
-	}
-
-	return $o;
-}
-
-function follow_process(App $a, string $url)
-{
-	$return_path = 'follow?url=' . urlencode($url);
-
-	$result = Contact::createFromProbeForUser($a->getLoggedInUserId(), $url);
-
-	if ($result['success'] == false) {
-		// Possibly it is a remote item and not an account
-		follow_remote_item($url);
-
-		if ($result['message']) {
-			DI::sysmsg()->addNotice($result['message']);
-		}
-		DI::baseUrl()->redirect($return_path);
-	} elseif ($result['cid']) {
-		DI::baseUrl()->redirect('contact/' . $result['cid']);
-	}
-
-	DI::sysmsg()->addNotice(DI::l10n()->t('The contact could not be added.'));
-
-	DI::baseUrl()->redirect($return_path);
-}
-
-function follow_remote_item($url)
-{
-	$item_id = Item::fetchByLink($url, DI::userSession()->getLocalUserId());
-	if (!$item_id) {
-		// If the user-specific search failed, we search and probe a public post
-		$item_id = Item::fetchByLink($url);
-	}
-
-	if (!empty($item_id)) {
-		$item = Post::selectFirst(['guid'], ['id' => $item_id]);
-		if (DBA::isResult($item)) {
-			DI::baseUrl()->redirect('display/' . $item['guid']);
-		}
-	}
-}
diff --git a/src/Module/Follow.php b/src/Module/Follow.php
new file mode 100644
index 0000000000..2844e40bf2
--- /dev/null
+++ b/src/Module/Follow.php
@@ -0,0 +1,240 @@
+<?php
+/**
+ * @copyright Copyright (C) 2010-2022, the Friendica project
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ *
+ */
+
+namespace Friendica\Module;
+
+use Friendica\App;
+use Friendica\BaseModule;
+use Friendica\Content\Widget\VCard;
+use Friendica\Core\Config\Capability\IManageConfigValues;
+use Friendica\Core\L10n;
+use Friendica\Core\Protocol;
+use Friendica\Core\Renderer;
+use Friendica\Core\Session\Capability\IHandleUserSessions;
+use Friendica\Model\Contact;
+use Friendica\Model\Item;
+use Friendica\Model\Post;
+use Friendica\Model\Profile;
+use Friendica\Model\User;
+use Friendica\Navigation\SystemMessages;
+use Friendica\Network\HTTPException\ForbiddenException;
+use Friendica\Network\Probe;
+use Friendica\Util\Profiler;
+use Friendica\Util\Strings;
+use Psr\Log\LoggerInterface;
+
+class Follow extends BaseModule
+{
+	/** @var IHandleUserSessions */
+	protected $session;
+	/** @var SystemMessages */
+	protected $sysMessages;
+	/** @var App */
+	protected $app;
+	/** @var IManageConfigValues */
+	protected $config;
+	/** @var App\Page */
+	protected $page;
+
+	public function __construct(L10n $l10n, App\BaseURL $baseUrl, App\Arguments $args, LoggerInterface $logger, Profiler $profiler, Response $response, IHandleUserSessions $session, SystemMessages $sysMessages, App $app, IManageConfigValues $config, App\Page $page, array $server, array $parameters = [])
+	{
+		parent::__construct($l10n, $baseUrl, $args, $logger, $profiler, $response, $server, $parameters);
+
+		$this->session     = $session;
+		$this->sysMessages = $sysMessages;
+		$this->app         = $app;
+		$this->config      = $config;
+		$this->page        = $page;
+	}
+
+	protected function post(array $request = [])
+	{
+		parent::post($request);
+
+		if (!$this->session->getLocalUserId()) {
+			throw new ForbiddenException($this->t('Access denied.'));
+		}
+
+		if (!empty($request['url'])) {
+			$this->baseUrl->redirect($request['url']);
+		}
+
+		$url = Probe::cleanURI($this->session->get('url'));
+	}
+
+	protected function content(array $request = []): string
+	{
+		$returnPath = 'contact';
+
+		if (!$this->session->getLocalUserId()) {
+			$this->sysMessages->addNotice($this->t('Permission denied.'));
+			$this->baseUrl->redirect($returnPath);
+		}
+
+		$uid = $this->session->getLocalUserId();
+		$url = Probe::cleanURI(trim($request['url'] ?? ''));
+
+		// Issue 6874: Allow remote following from Peertube
+		if (strpos($url, 'acct:') === 0) {
+			$url = str_replace('acct:', '', $url);
+		}
+
+		if (empty($url)) {
+			$this->baseUrl->redirect($returnPath);
+		}
+
+		$submit = $this->t('Submit Request');
+
+		// Don't try to add a pending contact
+		$userContact = Contact::selectFirst(['pending'], [
+			"`uid` = ? AND ((`rel` != ?) OR (`network` = ?)) AND (`nurl` = ? OR `alias` = ? OR `alias` = ?) AND `network` != ?",
+			$uid, Contact::FOLLOWER, Protocol::DFRN,
+			Strings::normaliseLink($url),
+			Strings::normaliseLink($url), $url,
+			Protocol::STATUSNET]);
+
+		if (!empty($userContact['pending'])) {
+			$this->sysMessages->addNotice($this->t('You already added this contact.'));
+			$submit = '';
+		}
+
+		$contact = Contact::getByURL($url, true);
+
+		// Possibly it is a mail contact
+		if (empty($contact)) {
+			$contact = Probe::uri($url, Protocol::MAIL, $uid);
+		}
+
+		if (empty($contact) || ($contact['network'] == Protocol::PHANTOM)) {
+			// Possibly it is a remote item and not an account
+			$this->followRemoteItem($url);
+
+			$this->sysMessages->addNotice($this->t('The network type couldn\'t be detected. Contact can\'t be added.'));
+			$submit  = '';
+			$contact = ['url' => $url, 'network' => Protocol::PHANTOM, 'name' => $url, 'keywords' => ''];
+		}
+
+		$protocol = Contact::getProtocol($contact['url'], $contact['network']);
+
+		if (($protocol == Protocol::DIASPORA) && !$this->config->get('system', 'diaspora_enabled')) {
+			$this->sysMessages->addNotice($this->t('Diaspora support isn\'t enabled. Contact can\'t be added.'));
+			$submit = '';
+		}
+
+		if (($protocol == Protocol::OSTATUS) && $this->config->get('system', 'ostatus_disabled')) {
+			$this->sysMessages->addNotice($this->t("OStatus support is disabled. Contact can't be added."));
+			$submit = '';
+		}
+
+		if ($protocol == Protocol::MAIL) {
+			$contact['url'] = $contact['addr'];
+		}
+
+		if (!empty($request['auto'])) {
+			$this->process($contact['url']);
+		}
+
+		$request = $this->baseUrl . '/follow';
+		$tpl     = Renderer::getMarkupTemplate('auto_request.tpl');
+
+		$owner = User::getOwnerDataById($uid);
+		if (empty($owner)) {
+			$this->sysMessages->addNotice($this->t('Permission denied.'));
+			$this->baseUrl->redirect($returnPath);
+		}
+
+		$myaddr = $owner['url'];
+
+		$output = Renderer::replaceMacros($tpl, [
+			'$header'         => $this->t('Connect/Follow'),
+			'$pls_answer'     => $this->t('Please answer the following:'),
+			'$your_address'   => $this->t('Your Identity Address:'),
+			'$url_label'      => $this->t('Profile URL'),
+			'$keywords_label' => $this->t('Tags:'),
+			'$submit'         => $submit,
+			'$cancel'         => $this->t('Cancel'),
+
+			'$request'  => $request,
+			'$name'     => $contact['name'],
+			'$url'      => $contact['url'],
+			'$zrl'      => Profile::zrl($contact['url']),
+			'$myaddr'   => $myaddr,
+			'$keywords' => $contact['keywords'],
+
+			'$does_know_you' => ['knowyou', $this->t('%s knows you', $contact['name'])],
+			'$addnote_field' => ['dfrn-request-message', $this->t('Add a personal note:')],
+		]);
+
+		$this['aside'] = '';
+
+		if (!in_array($protocol, [Protocol::PHANTOM, Protocol::MAIL])) {
+			$this['aside'] = VCard::getHTML($contact);
+
+			$output .= Renderer::replaceMacros(Renderer::getMarkupTemplate('section_title.tpl'),
+				['$title' => $this->t('Status Messages and Posts')]
+			);
+
+			// Show last public posts
+			$output .= Contact::getPostsFromUrl($contact['url']);
+		}
+
+		return $output;
+	}
+
+	protected function process(string $url)
+	{
+		$returnPath = 'follow?rul=' . urlencode($url);
+
+		$result = Contact::createFromProbeForUser($this->app->getLoggedInUserId(), $url);
+
+		if (!$result['success']) {
+			// Possibly it is a remote item and not an account
+			$this->followRemoteItem($url);
+
+			if (!empty($result['message'])) {
+				$this->sysMessages->addNotice($result['message']);
+			}
+
+			$this->baseUrl->redirect($returnPath);
+		} else if (!empty($result['cid'])) {
+			$this->baseUrl->redirect('contact/' . $result['cid']);
+		}
+
+		$this->sysMessages->addNotice($this->t('The contact could not be added.'));
+		$this->baseUrl->redirect($returnPath);
+	}
+
+	protected function followRemoteItem(string $url)
+	{
+		$itemId = Item::fetchByLink($url, $this->session->getLocalUserId());
+		if (!$itemId) {
+			// If the user-specific search failed, we search and probe a public post
+			$itemId = Item::fetchByLink($url);
+		}
+
+		if (!empty($itemId)) {
+			$item = Post::selectFirst(['guid'], ['id' => $itemId]);
+			if (!empty($item['guid'])) {
+				$this->baseUrl->redirect('display/' . $item['guid']);
+			}
+		}
+	}
+}
diff --git a/static/routes.config.php b/static/routes.config.php
index 62742b6593..a478ff05bb 100644
--- a/static/routes.config.php
+++ b/static/routes.config.php
@@ -418,6 +418,7 @@ return [
 	'/filed'                => [Module\Search\Filed::class,          [R::GET]],
 	'/filer[/{id:\d+}]'     => [Module\Filer\SaveTag::class,         [R::GET]],
 	'/filerm/{id:\d+}'      => [Module\Filer\RemoveTag::class,       [R::GET, R::POST]],
+	'/follow[/{url}]'       => [Module\Follow::class,                [R::GET, R::POST]],
 	'/follow_confirm'       => [Module\FollowConfirm::class,         [R::GET, R::POST]],
 	'/followers/{nickname}' => [Module\ActivityPub\Followers::class, [R::GET]],
 	'/following/{nickname}' => [Module\ActivityPub\Following::class, [R::GET]],