diff --git a/app/controllers/authenticated_controller.php b/app/controllers/authenticated_controller.php
index f50d478c9fad931612d0c32b74dc1acc614446c9..3f4da39307fc6d5a90d9b5876ee2308aff1f26ef 100644
--- a/app/controllers/authenticated_controller.php
+++ b/app/controllers/authenticated_controller.php
@@ -12,7 +12,7 @@
  * the License, or (at your option) any later version.
  */
 
-class AuthenticatedController extends StudipController
+abstract class AuthenticatedController extends StudipController
 {
     protected $with_session = true;  //we do need to have a session for this controller
     protected $allow_nobody = false; //nobody is not allowed and always gets a login-screen
diff --git a/cli/Commands/SORM/DescribeModels.php b/cli/Commands/SORM/DescribeModels.php
index 668de5bdd25f7d3a9b171c8339234a092cf30a86..1387a1640b342c1749de5259d8241e97d74a1713 100644
--- a/cli/Commands/SORM/DescribeModels.php
+++ b/cli/Commands/SORM/DescribeModels.php
@@ -29,7 +29,7 @@ final class DescribeModels extends AbstractCommand
     protected function configure(): void
     {
         $this->setDescription('Describe models');
-        $this->setHelp('This command will add neccessary @property annotations to SimpleORMap classes in a folder');
+        $this->setHelp('This command will add neccessary @property and @method annotations to SimpleORMap classes in a folder');
 
         $this->addArgument(
             'folder',
diff --git a/cli/Commands/Trails/DescribeControllers.php b/cli/Commands/Trails/DescribeControllers.php
new file mode 100644
index 0000000000000000000000000000000000000000..396498a023c4ca7b4c90982ea9303dcd0e181257
--- /dev/null
+++ b/cli/Commands/Trails/DescribeControllers.php
@@ -0,0 +1,270 @@
+<?php
+namespace Studip\Cli\Commands\Trails;
+
+use SimpleORMapCollection;
+use Studip\Cli\Commands\AbstractCommand;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Helper\ProgressBar;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+
+final class DescribeControllers extends AbstractCommand
+{
+    protected static $defaultName = 'trails:describe';
+
+    private $progress;
+
+    protected function configure(): void
+    {
+        $this->setDescription('Describe controllers');
+        $this->setHelp('This command will add neccessary @property annotations to SimpleORMap classes in a folder');
+
+        $this->addArgument(
+            'folder',
+            InputArgument::OPTIONAL,
+            'Folder to scan (will default to app/controllers)',
+            'app/controllers'
+        );
+
+        $this->addOption(
+            'recursive',
+            'r',
+            InputOption::VALUE_NEGATABLE,
+            'Scan into subfolders recursively'
+        );
+        $this->addOption(
+            'bootstrap',
+            'b',
+            InputOption::VALUE_OPTIONAL,
+            'Execute bootstrap file before scanning folder'
+        );
+    }
+
+    public function execute(InputInterface $input, OutputInterface $output)
+    {
+        $bootstrap = $input->getOption('bootstrap');
+        if ($bootstrap) {
+            if (!file_exists($bootstrap)) {
+                throw new \Exception("Invalid bootstrap file {$bootstrap} provided");
+            }
+            require_once $bootstrap;
+        }
+
+        $recursive = $input->getOption('recursive') ?? false;
+        $folder = $input->getArgument('folder');
+        $iterator = $this->getFolderIterator($folder, $recursive, ['php']);
+
+        $this->progress = new ProgressBar($output, iterator_count($iterator));
+        $this->progress->start();
+
+        foreach ($iterator as $file) {
+            $filename = $file->getFilename();
+
+            if (!is_writable($file->getRealPath())) {
+                $this->outputForFile(
+                    $output,
+                    '<comment>Skipping not writable file ' . $this->relativeFilePath($file->getPathname()) . '</comment>'
+                );
+                continue;
+            }
+
+            $class_name = $this->getClassNameFromFile($file->getPathname());
+            if ($class_name === null) {
+                $this->outputForFile(
+                    $output,
+                    "Could not determine class name for file {$filename} ({$file->getPathName()})",
+                    OutputInterface::VERBOSITY_VERBOSE
+                );
+                continue;
+            }
+
+            require_once $file->getPathName();
+
+            if (!class_exists($class_name) || !is_subclass_of($class_name, \Trails_Controller::class)) {
+                $this->outputForFile(
+                    $output,
+                    "Skipping invalid class file {$filename} (class {$class_name} / file {$file->getPathName()})",
+                    OutputInterface::VERBOSITY_VERBOSE
+                );
+                continue;
+            }
+
+            try {
+                $reflection = new \ReflectionClass($class_name);
+            } catch (\Error $e) {
+                $this->outputForFile(
+                    $output,
+                    "<error>Could not get reflection for class {$class_name} ({$e->getMessage()})</error>"
+                );
+                continue;
+            }
+
+            if ($reflection->isAbstract()) {
+                $this->outputForFile(
+                    $output,
+                    "Skipping abstract class {$class_name}",
+                    OutputInterface::VERBOSITY_VERBOSE
+                );
+                continue;
+            }
+
+            if (false && $this->updateDocBlockOfClass($reflection, $properties, $methods)) {
+                $this->outputForFile(
+                    $output,
+                    '<info>Updated ' . $this->relativeFilePath($file->getPathname()) . '</info>'
+                );
+            } else {
+                $this->outputForFile(
+                    $output,
+                    'No changes in ' . $this->relativeFilePath($file->getPathname()),
+                    OutputInterface::VERBOSITY_VERBOSE
+                );
+            }
+        }
+
+        $this->progress->clear();
+
+        return Command::SUCCESS;
+    }
+
+    private function outputForFile($output, ...$args)
+    {
+        $this->progress->advance();
+        $this->progress->clear();
+        $output->writeln(...$args);
+        $this->progress->display();
+
+    }
+
+    private function getPHPType($info)
+    {
+        if (preg_match('/^(?:tiny|small|medium|big)?int(?:eger)?/iS', $info['type'])) {
+            return 'int';
+        }
+
+        if (preg_match('/^(?:decimal|double|float|numeric)/iS', $info['type'])) {
+            return 'float';
+        }
+
+        if (preg_match('/^bool(?:ean)?/iS', $info['type'])) {
+            return 'bool';
+        }
+
+        return 'string';
+    }
+
+    private function updateDocBlockOfClass(\ReflectionClass $reflection, array $properties, array $methods): bool
+    {
+        $has_docblock = (bool) $reflection->getDocComment();
+        $docblock     = $reflection->getDocComment() ?: $this->getDefaultDocblock();
+
+        $docblock_lines = array_map('rtrim', explode("\n", $docblock));
+
+        $properties_started = false;
+        $docblock_lines = array_filter($docblock_lines, function ($line) use (&$properties_started) {
+            $line = ltrim($line, '* ');
+            if ($properties_started) {
+                return $line === '/';
+            }
+
+            $properties_started = strpos($line, '@property ') === 0;
+            return !$properties_started;
+        });
+
+        $docblock_lines = array_reverse($docblock_lines);
+        while ($docblock_lines[1] === ' *') {
+            array_splice($docblock_lines, 1, 1);
+        }
+        $docblock_lines = array_reverse($docblock_lines);
+
+        $properties = array_map(function ($variable, $property) {
+            return " * @property {$property['type']} \${$variable} {$property['description']}";
+        }, array_keys($properties), array_values($properties));
+
+        array_unshift($properties, ' *');
+        array_splice($docblock_lines, -1, 0, $properties);
+
+        $methods = array_map(function ($method) {
+            return " * @method {$method}";
+        }, $methods);
+
+        array_unshift($methods, ' *');
+        array_splice($docblock_lines, -1, 0, $methods);
+
+        $new_docblock = implode("\n", $docblock_lines);
+
+        if ($docblock === $new_docblock) {
+            return false;
+        }
+
+        $contents = file_get_contents($reflection->getFileName());
+        if ($has_docblock) {
+            $contents = str_replace($docblock, $new_docblock, $contents);
+        } else {
+            $contents = preg_replace(
+                '/^class/m',
+                $new_docblock . "\nclass",
+                $contents,
+                1
+            );
+        }
+
+        file_put_contents($reflection->getFileName(), $contents);
+
+        return true;
+    }
+
+    private function getDefaultDocBlock(): string
+    {
+        return implode("\n", [
+            '/**',
+            ' * @license GPL2 or any later version',
+            ' */',
+        ]);
+    }
+
+    /**
+     * @see https://stackoverflow.com/a/14250011
+     */
+    private function getClassNameFromFile(string $filename, string $expected_class = null): ?string
+    {
+        $code = file_get_contents($filename);
+        $tokens = token_get_all($code);
+
+        $namespace = '';
+
+        for ($i = 0, $l = count($tokens); $i < $l; $i += 1) {
+            if ($tokens[$i][0] === T_NAMESPACE) {
+                for ($j= $i + 1; $j < $l; $j += 1) {
+                    if ($tokens[$j][0] === T_STRING) {
+                        $namespace .= "\\" . $tokens[$j][1];
+                    } elseif ($tokens[$j] === '{' || $tokens[$j] === ';') {
+                        break;
+                    }
+                }
+            }
+            if ($tokens[$i][0] === T_CLASS) {
+                for ($j = $i + 1; $j < $l; $j += 1) {
+                    if ($tokens[$j] === '{') {
+                        return ltrim($namespace . "\\" . $tokens[$i + 2][1], "\\");
+                    }
+                }
+            }
+        }
+        return null;
+    }
+
+    private function makeMethods(\ReflectionClass $reflection, string $field): array
+    {
+        $result = [];
+        foreach (self::METHODS_TEMPLATES as $name => $template) {
+            $method = sprintf($name, $field);
+            if (!$reflection->hasMethod($method)) {
+                $result[] = sprintf($template, $field);
+            }
+        }
+        return $result;
+    }
+}
diff --git a/cli/studip b/cli/studip
index af6273f62b89690f2186fafb16dfb671498f5dab..5bfd0f643fdee33468e4120acb462085dc8850d0 100755
--- a/cli/studip
+++ b/cli/studip
@@ -52,6 +52,7 @@ $commands = [
     Commands\Plugins\I18N\I18NCompile::class,
     Commands\Resources\UpdateBookingIntervals::class,
     Commands\SORM\DescribeModels::class,
+    Commands\Trails\DescribeControllers::class,
     Commands\Users\UserDelete::class,
     Commands\Users\UserDelete::class,
 ];