Receiving a user's registration submission and inserting row into database #2
While it is sensible to some people to precisely specify the cause of the submission failure, I prefer to return compound messages rather than a length error message and a quality error message.  I would probably save on screen space (versus piling upto a dozen separate email messages on the user), and merge some responses like:

First name is a required field and may only contain letters and/or digits.

Also, you are repeating yourself a few times with these error messages and you can easily implement a looped battery of checks -- and it is simpler to do that within the $_POST array.  Not only does this avoid some code bloat, you are assured to have consistent error message and reduce your chances of copy-pasting typos.  (Maybe you didn't realize that you wrote last name in the username errors  -- this is the kind of thing that really perplexes users!)
Note: ctype_alnum() will return false on a zero-length string so the !trim() can be omitted.
$alnums = ['first_name', 'last_name', 'username'];  // whitelist of required alphanumeric fields
foreach ($alnums as $field) {
    if (!isset($_POST[$field]) || !ctype_alnum($_POST[$field])) { 
        $errors[] = &quot;$field is a required field and may only contain letters and/or digits.&quot;;
    }
}

if (!isset($_POST['email']) || !filter_var($_POST['email'], FILTER_VALIDATE_EMAIL)) {
    $errors[] = &quot;Email is a required field and must be valid.&quot;;
}

if (!isset($_POST['pw'], $_POST['pw2']) || $pw !== $pw2) {
    $errors[] = &quot;Password and Password 2 are required fields and must be identical.&quot;;
}

Ultimately, all of these server-side checks are to protect you and your database -- NOT to help your users.  For the best possible user experience (UX), you need to duplicate all of these validations with javascript onsubmit of the form.  This way you inform the user as quickly as possible AND avoid making a fruitless trip to your server.
You can check for unique usernames and email addresses in a single trip to the database.  Because the earlier validation assures no leading or trailing whitespace, the posted data will match the respective result set values (iow, no trimming is needed).
if (!$errors) {
    $stmt = $con-&gt;prepare(&quot;SELECT username, email FROM users WHERE username=? OR email=?&quot;);
    $stmt-&gt;bind_param(&quot;ss&quot;, $_POST['username'], $_POST['email']);
    $stmt-&gt;execute();
    foreach ($stmt-&gt;get_result() as $row) {
        foreach (['username', 'email'] as $field) {
            if ($row[$field] == $_POST[$field]) {
                $errors[] = &quot;Sorry, the submitted $field is already in use.&quot;;
            }
        }
    }
}

Once you have fully validated all incoming data as valid, THEN you can happily insert the new user into your database and save the SESSION data.  By the way, I don't recommend changing the naming convention from username to id.
If any of the checkpoints are failed then present your $errors.  As already pointed out, you are not actually displaying your error messages.  In simplest terms, you could create &lt;div&gt; tags as you loop.
if (!$errors) {
    $pw = password_hash($_POST['pw'], PASSWORD_BCRYPT, ['cost' =&gt; 14]);
    $stmt = $con-&gt;prepare(&quot;INSERT INTO users (first_name, last_name, username, email, pw) VALUES (?, ?, ?, ?, ?)&quot;);
    $stmt-&gt;bind_param(
        &quot;sssss&quot;,
        $_POST['first_name'],
        $_POST['last_name'],
        $_POST['username'],
        $_POST['email'],
        $_POST['pw']
    );
    $stmt-&gt;execute();
    
    $_SESSION[&quot;username&quot;] = $_POST['username'];  // I always expect an id to be an integer
    header(&quot;Location: ../../index.php&quot;);
    exit();
}

foreach ($errors as $error) {
    echo &quot;&lt;div class=\&quot;error\&quot;&gt;$error&lt;/div&gt;&quot;;
}

None of the above snippets have been tested; I make no guarantees that they will work &quot;out of the box&quot;.