How do I populate report page using a controller? I'm following this tutorial on creating a report page for a custom plugin, but note that I'm using Drupal 9, and not Drupal 8 as per the tutorial.
I've created a report() method inside my implementation of ControllerBase:
&lt;?php
/**
 * file
 * Contains \Drupal\rsvplist\Controller\ReportController
 */

namespace Drupal\rsvplist\Controller;

use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Database\Database;
use Symfony\Component\HttpFoundation\Response;

/**
 * Controller for RSVP List Report
 */

class ReportController extends ControllerBase {

  /**
   * Gets all RSVPs for all nodes.
   *
   * @return array
   */
  protected function load() {
    $select = Database::getConnection()-&gt;select('rsvplist', 'r');

    // join the users table, so we can get the entry creator's username
    $select-&gt;join('users_field_data', 'u', 'r.uid = u.uid');

    // join the node table, so we can get the event's name
    $select-&gt;join('node_field_data', 'n', 'r.nid = n.nid');

    // Select the specific fields for the output
    $select-&gt;addField('u', 'name', 'username');
    $select-&gt;addField('n', 'title');
    $select-&gt;addField('r', 'mail');

    $entries = $select-&gt;execute()-&gt;fetchAll(\PDO::FETCH_ASSOC);

    return $entries;
  }

  /*
   * Creates the report page
   *
   * @return array
   *   Render array for report output.
   */
  public function report() {
    $headers = [
      t('Name'),
      t('Event'),
      t('Email'),
    ];

    $rows = [];
    foreach ($this-&gt;load() as $entry) {
      // sanitise each entry
      $rows[] = array_map('Drupal\Component\Utility\SafeMarkup::checkPlain', $entry);
    }

    return [
      ['table'] =&gt; [
        '#type' =&gt; 'table',
        '#header' =&gt; $headers,
        '#rows' =&gt; $rows,
        '#empty' =&gt; t('No entries available'),
      ],
      ['message'] =&gt; [
        '#markup' =&gt; t('Below is a list of all Event RSVPs including username, email address and the name of the event they will be attending.'),
      ]];

  }
}

My routing file seems to be set up correctly since I see the link to the report page:

However, when I navigate to the report page, I just see a blank page;

I thought perhaps I'm supposed to return a HTTPFoundation\Response object, so I tried this;
$response = new Response();
$response-&gt;setMaxAge(0);
$response-&gt;setContent('hello world');

return $response

But that seemed to have the effect of completely nuking the HTML of my page and just printing &quot;hello world&quot; to the screen with all menu items gone, so clearly that's not correct either. What am I doing wrong here? Better yet, where should I go to learn about how to do this correctly?
A controller needs to return a render array for the page content. It is explained in Routing API and page controllers / Route controllers for simple routes.

The only requirement is that the method specified in your *.routing.yml file returns:

A render array (see the Theme and render topic for more information). This render array is then rendered in the requested format (HTML, dialog, modal, AJAX are supported by default). In the case of HTML, it will be surrounded by blocks by default: the Block module is enabled by default, and hence its Page Display Variant that surrounds the main content with blocks is also used by default.
A \Symfony\Component\HttpFoundation\Response object.


It's exactly what DbLogController::overview(), the controller method for the Recent log messages (/admin/reports/dblog) route, does.
  $build['dblog_table'] = [
    '#type' =&gt; 'table',
    '#header' =&gt; $header,
    '#rows' =&gt; $rows,
    '#attributes' =&gt; [
      'id' =&gt; 'admin-dblog',
      'class' =&gt; [
        'admin-dblog',
      ],
    ],
    '#empty' =&gt; $this-&gt;t('No log messages available.'),
    '#attached' =&gt; [
      'library' =&gt; [
        'dblog/drupal.dblog',
      ],
    ],
  ];
  $build['dblog_pager'] = [
    '#type' =&gt; 'pager',
  ];
  return $build;

(The Database Logging module also adds a pager, which is helpful when the rows to show could be too much, or they would make the rest of the page rendered too low in the page.)
What is wrong in your code is that it uses an array containing a string as array index, instead of a string. Any PHP version that supports the short array syntax would throw Fatal error: Illegal offset type. (The other versions would throw Parse error: syntax error, unexpected '[' since they don't support the short array syntax.)
The return instruction should be the following one.
    return [
      'table' =&gt; [
        '#type' =&gt; 'table',
        '#header' =&gt; $headers,
        '#rows' =&gt; $rows,
        '#empty' =&gt; t('No entries available'),
      ],
      'message' =&gt; [
        '#markup' =&gt; t('Below is a list of all Event RSVPs including username, email address and the name of the event they will be attending.'),
      ]
    ];