Episode 12 of a series about building an MMO from scratch, with the focus on the engineering around the game. Our game gets a website. We make a Laravel app with Composer, in Docker, so PHP doesn't have to be installed on our machine, and run it on FrankenPHP, a web server with PHP built in, as one more service in our Docker Compose stack, with its sessions in Redis. The front page ranks the players by experience, from the tables the game server saves them in, with the levels worked out like the game server does. Pico CSS styles the plain HTML from a CDN, so there's no CSS of our own and no npm build. Then accounts: you can create one on the website, log in and log out, on the game's own accounts table, with Laravel's validation, CSRF protection and rate limiting. The website hashes passwords with Argon2id with the login server's libsodium settings, so an account made on the website logs into the game, and the other way round. We go over sharing a database between two programs, why the website gets no migrations of its own, and the hash string that carries its own settings. Tests pin the levels and the hashes, and CI runs them.
The code
Each step on screen is a tag in the game's repository. The code at the end of the episode is ep012/end.
Transcript
Intro
0:00 Hello and welcome back to episode twelve, where our game gets a website. On it, you can create an account, log in, and see who has the most experience. Let's begin with a quick look at where we're going to be by the end of the episode.
0:14 Our game has a website now, and its front page ranks the players by their experience, with their level next to it. I log in with the account I play the game with, and my own name is marked in the rankings.
0:26 On the website of the series you can find more information about the project. The code for the project is open source on GitHub, tagged at each step of each episode so that you can follow along.
Last time
0:38 Now, before we get to today's work, let's quickly go over what we did in the last two episodes. Our world got its first monster, Quaternius's skeleton, which is in the public domain, so it's in our repo as it was downloaded. The server owns the monsters, and runs their AI each tick, so they wander around where they spawned. Players and monsters got health and levels, and players got experience. The game server saves each player's experience in MySQL, in the players table, and sends it to that player's client alone. Then our players got a sword, which they swing with Space. The server decides what it hits, and who gets the experience when a monster dies. And the monsters fight back, going for any player who comes near their home, so our players can die too, and come back ten seconds later. That experience in MySQL is what our website is going to rank today.
Today
1:29 Today, we're going to make our website with Laravel, a PHP framework, and make the app in Docker, so you don't need PHP on your machine. It runs on FrankenPHP, as one more service in our Docker Compose stack, next to MySQL and Redis. Its front page ranks the players, from the table the game server saves them in. For the looks, we're going to use Pico CSS, so we don't write any CSS of our own. Then accounts, so you can create one on the website, log in and log out. The website uses the game's own accounts table, with the login server's password hashes, so an account made on the website logs into the game, and the other way round. All right, let's get started, with where the website fits next to our game.
A website for the game
2:14 Most MMOs have a website next to the game, for what you do outside of playing. Back when I played Cabal and AION, that's where you made your account, and where you looked up the rankings, to see who had the highest level on your server. Your browser talks to the website over HTTP, and the website is a program of its own, in a container of its own. It keeps its sessions in Redis, where a session records which account you're logged in as. And it reads and writes MySQL directly, through a connection of its own. It doesn't talk to our login server or our game server, as the three programs share the database instead. So the accounts table gets a second program writing to it, in another language. The other option is for the website to go through the login server, over our protocol, and leave the table to the login server. That keeps the rules in one place, but we'd need a protobuf client in PHP, and the website would go down whenever the login server does. Sharing the database is quite common for a game's website. So we're going to do that, and make sure that the two programs agree, with a test.
A Laravel app
3:19 To build the website, we're going to use Laravel, by Taylor Otwell. I've worked with web technologies from Python to PHP, and for a website with accounts, Laravel gives us most of what we need out of the box. This repo is its skeleton, the app you start from. It comes with routing, Blade templates for the HTML, and Eloquent for the database tables. And it has sessions, logins, validation, protection against forged requests, and rate limiting, which we're all going to use today. Django in Python, or Rails in Ruby, would do the job too, as the ideas carry over: a route, a controller, a template and a model.
3:56 We make the app with Composer, which is PHP's package manager, like npm is for JavaScript. Instead of installing PHP and Composer on our machine, we run Composer's official image, pinned like our other images. Dash v mounts the game's repo at slash app in the container, and dash w makes that the working directory. So whatever Composer writes in slash app lands in the game's repo, on our machine. Create project copies Laravel's skeleton, at the version we pin, into a new folder, web, and downloads about a hundred libraries into web slash vendor. And it runs Laravel's setup, which makes a key, a SQLite database, and the tables Laravel comes with, which we're going to undo, as our tables are in MySQL. So the website lives in web, in the game's repo, next to the server and the client.
4:49 Let's go over the files and folders Laravel gave us, as we're going to work in most of them today. Public slash index dot P H P is where requests come in, as the web server hands each one to it, and it starts Laravel. Routes slash web dot P H P maps URLs to our code. In app goes our code: the controllers, which handle requests, and the models, one for each table. Resources slash views holds the Blade templates, which turn data into HTML. Config holds the settings, and most of them come from environment variables, such as the database's host. Composer dot json lists the libraries we depend on, and composer dot lock pins each one, and their dependencies, like the versions we pin in CMake. And two things stay out of git, in Laravel's git ignore: vendor, the downloaded libraries, and dot env, a file with this machine's settings and secrets.
Into Docker
5:44 PHP needs a web server in front of it, and for years, that's been Apache or nginx, with PHP running next to them. We're going to use FrankenPHP, by Kévin Dunglas, which the PHP Foundation looks after now. It's a web server, Caddy, with PHP built into it, so it's one program in one container. Its Docker image serves whatever's in slash app slash public, which is where Laravel's index dot P H P is going to be. It comes with a script, install PHP extensions, which builds and turns on PHP's extensions, such as P D O MySQL, which we need to talk to MySQL. And its docs show how to run it as a user that isn't root, which we're going to do too, like our servers.
6:30 The website gets a Dockerfile of its own, in the web folder, next to its code. We start from FrankenPHP's image, with PHP eight point five, pinned like our other images. And we name this stage php, as it's the base the website's image builds on, and later today, our tests too. Next come the extensions, as PHP keeps a lot of its features in extensions, which you turn on as you need them. We need three, starting with P D O MySQL, the MySQL driver for P D O, PHP's common interface to databases, which Laravel uses underneath. Redis is for the sessions, and zip is there as Composer downloads each library as a zip file, and has to unpack it. Then we need Composer, to install the website's libraries in the image. And copy dash dash from takes its binary out of Composer's official image, at the version we made the app with. The working directory is slash app, as that's where FrankenPHP serves the website from. Then the web stage, which starts from php, and where we copy composer dot json and composer dot lock in first, on their own, and run composer install. Dash dash no dev leaves out the libraries we need for development alone, such as PHPUnit, so they don't end up on a server. No scripts and no autoloader skip the steps that need our code, as it isn't in the image yet. The libraries get a layer of their own for Docker's cache, so changing a controller doesn't download a hundred libraries again. Then we copy in the rest, our code, and composer dump autoload writes the autoloader, which is how PHP finds a class's file from its name. Dash dash optimize has it list all our classes up front, which makes requests quicker in production. Next, we're going to run as a user that isn't root. So chown hands the folders the website writes to over to www data, Debian's usual user for running a website. Those are Laravel's storage and bootstrap cache, and Caddy's state, in slash config and slash data. Server name colon eight zero eight zero tells Caddy which address to answer on. By default, FrankenPHP serves localhost over HTTPS on port four four three, with a certificate it makes up on the spot. With no host name in front of the port, Caddy skips the certificate and serves plain HTTP, on port eight thousand and eighty. And the port is that high, as a user that isn't root can't open a port below a thousand and twenty-four. So user www data runs the server from here on, like the ubuntu user runs ours, and expose documents the port. Then the website's docker ignore, which keeps out dot env, as compose sets those settings in the container, and vendor, as the image installs its own. Node modules, the PHP files Laravel caches with our machine's paths, the logs and the installer's SQLite database stay out too. In compose dot yaml, we add a web service, built from the web folder. Laravel reads its settings from a dot env file or from environment variables, and when both have a setting, the environment wins. So compose sets them all, and the image needs no dot env. App name, nanoMMO, is what our pages are going to call the game. App key is the key Laravel encrypts its cookies with. And like the MySQL password above it, it's fine in the file for our own machines, while a public website would get a new one. DB connection is mysql, instead of SQLite, and the DB host is mysql too, compose's name for that service. The database, the user and the password are all nanommo, like for our servers, and the Redis host is redis. Then the session driver and the cache store, which are both Redis. Laravel's skeleton keeps sessions in a database table, which would need a migration, and a session in a file would go away with the container. In Redis, a session survives the website's container being replaced, and we run Redis already, for the login tokens. Then port eight thousand and eighty, published on our machine, and depends on waits for MySQL and Redis to be healthy, like our servers do. And last, the game's own docker ignore, at the top of the repo, gets a line with web. The C++ image copies the whole repo in, so without it, each change to the website would make Docker build the server again, and send it the website's vendor folder too.
10:57 Docker compose up builds the website's image and starts it, and starts MySQL and Redis first, as web depends on them. Docker compose ps shows the container running, with port eight thousand and eighty published.
11:14 On localhost, port eight thousand and eighty, FrankenPHP serves Laravel's welcome page from our container. Behind it, Laravel has started a session already, and it's in Redis. This page is the skeleton's, so let's replace it with our rankings.
Rankings
11:30 The game server saves each player in the players table, under its account id: where it is, and all the experience it has. The names are in the accounts table, which the login server writes to. So we join the two on the account id, sort by experience, most first, and take the first hundred. The level isn't in the database, as the game server works it out from the experience, with level for, from episode ten. We could have the game server save the level as well, but a column that's worked out from another one can get out of step with it. So the rule is going to be in two places, in two languages. The rule is a few lines, and a test on each side checks it.
12:10 We start with the rule, in a class of its own, in app, Level dot P H P, as it doesn't belong to a table or to a page. And on its own, a test can call it without starting Laravel. This is our first PHP, so let's take it slowly. A PHP file opens with the PHP tag, which tells PHP that what follows is code, and not text to print as it is. Then its namespace, App, which Composer maps to the app folder. So when other code uses App's Level, the autoloader loads it from app slash Level dot P H P. Then the class, Level, with a single function in it, called for. It's public and static, so we call it on the class, as Level for, since there's no state to keep between calls that would need an object. It takes the experience as an int and returns an int, and PHP checks those types on each call. So handing it something that isn't a number fails right there. Then the rule from the game server, where variables in PHP start with a dollar sign, and we start at level one. While the experience left is at least a hundred times the level, we take that much off, and go up a level. So a hundred gets you to level two, two hundred more to level three, three hundred more to level four, and so on. We could work the level out with a formula and a square root instead. But this is the loop from level for, in C++, so when you put the two side by side, it's easy to see that they agree. The C++ also returns how far you are into your level, and how much the next one takes, for the client's experience bar. The rankings show just the level, so we return the level on its own.
13:50 Next, the code that answers a request for the rankings. In Laravel, that's a controller, and controllers go in app slash Http slash Controllers, with a namespace that follows the folders, like Level's. Then two use lines, which let us name a class without its whole namespace, starting with Level, our own class from a moment ago. And then DB, which is one of the facades Laravel gives us. A facade gives us static calls, and Laravel hands each of them on to a service it has set up for us, here the database connection, from the settings in compose. So our code doesn't open a connection, or pass one around. Then the class, ranking controller, which extends Controller, an empty base class from the skeleton, for anything our controllers might share later on. Its function is underscore underscore invoke, one of PHP's magic methods, which makes an object callable like a function. A controller with a single job can be just that, and its route can point at the class. Then the query, where DB table players starts Laravel's query builder on the players table. The double colon calls a static function, like it does in C++. The arrow calls a method on an object, like it does through a pointer, and each of those calls adds a piece to the query. The builder writes the SQL for us, and sends the values apart from it, like our prepared statements in C++, which keeps out SQL injection. We join accounts for the names, and as it's an inner join, an account that hasn't played yet has no row in players, and stays out of the rankings. And order by desc, on the experience, puts the most experience first. Then we order by the username too, for when two players have equal experience. Otherwise, they'd come back in whatever order MySQL finds them, which can change from one page load to the next. Limit a hundred, as a page with all the players who ever played gets long, and the database does less work. And get takes the two columns we show, the username and the experience, and leaves the rest, such as the password hash, in the database. Then, for each player, we work out the level, with Level for, and put it on the row. We could write that loop in SQL too, but then the rule would be in three places. And it returns the view called rankings, with the players, as views are what Laravel calls its templates. Then routes slash web dot P H P, with a use line at the top for our controller. The route for slash used a function that showed the welcome page. Now it points at the ranking controller, and as the class has invoke, the class name is all the route needs.
16:29 Pico CSS, by Lucas Larroche, styles plain HTML: headings, tables, forms, buttons and a nav, with a light and a dark theme, which it picks from your system's setting. This page is its class-less version, which doesn't need any classes in our HTML. A header, a main and a footer in the body are its containers, centred on the page. And it's on jsDelivr, a CDN, so a link tag in our layout is all it takes, with no CSS of our own, and no build step. Laravel comes set up for Tailwind, which is great for designing a site of your own. But for us, it would mean writing classes on each element, and a build with Node.
17:09 Then the HTML, in Blade files in resources slash views, which are HTML with a few bits of Blade's own syntax, to print values, loop and so on. We start with a layout, the HTML that goes around all our pages, so that the head and the nav are written once. It's a component, a piece of a page that other pages use like an HTML tag, so it goes in components, and pages use it as x layout. First, the doctype, and html with lang en, so that screen readers and translators know the page is in English. In the head, the charset, U T F eight, which tells the browser how the page's bytes are encoded. Then the viewport, so that on a phone, the page is as wide as the screen, and isn't drawn for a desktop and shrunk down. Then color scheme, light dark, which tells the browser that the page works in both, so its scroll bars and form fields follow your system's setting too. The title is config, app dot name, in double curly braces, which is how Blade prints a value. It escapes any HTML in the value, so a value can't add tags to our page. App dot name is nanoMMO, from compose, so the game's name is set in one place. Then the link to Pico's class-less stylesheet on jsDelivr, pinned to version two point one point one in its URL. In the body goes a header with a nav, which Pico lays out as a bar when it holds two lists, the first on the left and the second on the right. So on the left goes the game's name, in strong, which makes it bold, and on the right, a link to the rankings. And then main, with slot in curly braces, which is where the page that uses the layout goes. Then the page, rankings dot blade dot P H P, which x layout wraps in our layout, so this file holds no more than what's on this page. It has a heading, Rankings, and a table, with no class on it, as Pico styles tables as they are. Its head has four columns: the rank, with a hash sign, then the name, the level and the experience. In its body, forelse, which is Blade's for each, with a branch for when the list is empty. Each player gets a row, and its first cell is loop's iteration, a counter Blade gives us inside a loop, from one. As the controller sorted the players, that counter is the rank. Then the username, the level and the experience, each in curly braces, so they're escaped too. And empty is for before anyone has played, with a single row across all four columns that says no one has played yet.
19:44 Now that slash shows the rankings, no route shows Laravel's welcome page any more, so it goes. Then the front end's build, as Laravel comes set up for Vite, a JavaScript build tool, and Tailwind, both installed with npm, from the list in package dot json. With Pico from a CDN, we have no CSS or JavaScript of our own to build, so package dot json goes. So do Vite dot config dot js, dot npmrc with npm's settings, and app dot css and app dot js, where our styles and scripts would go. That means no Node in our stack and no Node in our image. In composer dot json, the setup script, for a new machine, ran npm install and npm run build, so those two lines go. And dot env dot example is the template you'd copy to dot env, to run the website outside Docker, with PHP on your machine. App name is nanoMMO, like in compose, and the database connection, which was SQLite with the MySQL settings commented out, is now our MySQL. Its host is our machine, where compose publishes MySQL's port, with the nanommo database, user and password. Sessions and the cache go in Redis, like in compose, and the last line, Vite's app name, goes along with Vite.
21:03 Once it's built and started again, with docker compose up dash dash build, slash shows our rankings. Nobody has played on this fresh database, so to have something to rank, I've put six players into the tables by hand. Sam is first, with seventeen hundred and twenty experience, which is level six, as the levels up to six take fifteen hundred. And alice, with twelve hundred and fifty, is level five. Pico picked its dark theme from my system's setting, and the nav, the heading and the table are all its styles, with no CSS of ours.
Accounts
21:41 Now for accounts, where until now, the login server has been the one way into the accounts table. It creates accounts and checks passwords, for the client's login screen. The website is going to do both too, on that table. So the two have to agree on the rules for a name and a password. Names are three to sixteen letters, digits and underscores, and passwords are eight to a hundred and twenty-eight characters. Otherwise, the website could make a name the game refuses. And they have to agree on the hash, so that a password the website hashed logs into the game, and the other way round.
22:15 Here's one of the hashes the login server stores, as it's saved in the accounts table. It starts with the algorithm, Argon2id, and its version, nineteen. Then the settings it was hashed with: m is the memory, sixty-four mebibytes, written in kibibytes, t is two passes over it, and p is one thread. Then the salt, the random bytes that make two equal passwords hash differently, and the hash, both in base sixty-four. This is the P H C string format, from the Password Hashing Competition, and PHP's password functions read and write it, like libsodium does. As the string carries its own settings, either side can check a password against the other's hash. But Laravel hashes with bcrypt by default, which libsodium can't read, so an account made on the website wouldn't log into the game. And with Argon2id, Laravel uses four passes, where the login server uses two. When you log in, Laravel hashes your password again if its settings differ from the hash's, so it would rewrite the login server's hashes as people log in. They'd still work in the game, but we'd have two kinds of hashes in one table for no reason. So the website is going to use the login server's settings.
23:26 Laravel's docs cover logins in a chapter on authentication, and its starter kits come with pages and a users table of their own. We have our table already, so we're going to log people in by hand, as this section shows. Auth attempt takes the credentials from the form, finds the user, and checks the password against its hash. Further down is the rehashing on login that we just talked about. And artisan config publish hashing copies the hashing settings into our app, so that we can change them.
23:55 Laravel's skeleton comes with a User model, for a users table with a name, an email and a password. We don't have that table, and we're not going to make one, so User goes. In its place, Account, in app slash Models, for the accounts table that our login server uses already. A model is Eloquent's class for a table, and Eloquent, Laravel's O R M, turns rows into objects and back. So we can read and write a row without writing the SQL. It goes by naming conventions: a model called Account is the accounts table, with id as its key. Our table happens to follow them, so we don't have to tell it the table or the key. At the top go the use lines, starting with Fillable and Hidden, two attributes from Eloquent. A PHP attribute is data about a class that Laravel can read, a bit like an annotation in Java, and it's a class too, so it needs a use line. And a third one, without timestamps, and we're going to put all three on the class in a moment. Then Laravel's own User class, which we bring in under the name Authenticatable, as that's what it is for us: something you can log in as. The lines in square brackets, with a hash sign in front, are how PHP writes those attributes, and they go above the class. Fillable lists the columns that create is allowed to fill from an array: the username and the password hash. Any other key in that array is left out, so if we ever pass a whole form in, an extra field, say an id, can't set a column. Hidden keeps the password hash out when the model is turned into an array or JSON, so it can't leak into a page by accident. Without timestamps tells Eloquent not to write created at and updated at, as our table has no updated at, and MySQL fills in created at for us. Then the class, Account, which extends Authenticatable, so it brings what Laravel's auth needs from a model, such as how to get its id and its password hash. Auth password name points Laravel at password underscore hash, the login server's column, instead of a column called password. Remember token name is the column for a feature called remember me, which keeps you logged in after your session ends, with a token in a column of its own. Our table has no such column, and we're not going to change the login server's table for it, so an empty name turns remember me off. And casts marks the password hash as hashed, so when we set a password on the model, Eloquent hashes it on the way in, with Laravel's hasher. In config slash auth dot P H P, at the top, we use Account instead of User. And further down, the provider, which is what finds the account for a login, gets Account as its default model. Config slash hashing dot P H P is the file that config publish copied in, with Laravel's notes on each setting. We leave the comments as they are, and change two things in it. The driver is Argon2id, instead of bcrypt, with a comment over it: Argon2id, like the login server, so each reads the other's hashes. And the Argon settings get the login server's numbers, for the memory, the threads and the passes. As Laravel publishes the file, these read environment variables, with Laravel's defaults. We write them in the file instead, as they're an agreement with the login server, and not something to set differently on each machine. Above them, we write where they come from: libsodium's interactive limits, which the login server uses, sixty-four mebibytes and two passes. PHP's own Argon2 could use more threads, but libsodium works with a single thread, so a hash made with more wouldn't verify in the login server.
27:36 Laravel keeps a database's schema in migrations, which are PHP classes that create and change tables. They run one after the other, and Laravel keeps a table of which ones have run. It comes with three: the users, along with the sessions, then a cache, and a queue for jobs. Our tables are in db slash schema dot sql, from episode five, and the game servers depend on them. If the website changed the schema too, we'd have two places to look for what our tables are, and sooner or later they'd get out of step. So the website gets no migrations of its own, and these go. Our sessions and our cache are in Redis, and we don't queue any jobs, so we don't miss those tables either. The factory and the seeder go too, as they made fake users for tests and for a new database. And so does the folder's git ignore, which was there for the SQLite file. In composer dot json, the autoload entries for the factories and the seeders go with them. So do the steps in its scripts that made a SQLite database and migrated it. The docker ignore has no SQLite file to leave out any more, and dot env dot example's bcrypt rounds go, as we hash with Argon2id.
28:47 Now the controllers for accounts, starting with the one for making an account, register controller, next to the rankings one. At the top, it uses our Account model, and unique constraint violation exception, from Laravel, which we're going to catch. Then the Request, and Auth, the facade for logging people in and out. The controller has two actions, which is what Laravel calls the functions that answer routes. Create answers a get, and returns the view called register, which is going to be the form. Store answers the post, when the form is sent, and takes a Request, Laravel's object for the HTTP request. Laravel passes one in for us, as it reads the type from the function's parameters. First, validate checks the form's fields against a list of rules for each. If any rule fails, Laravel stops there, and sends you back to the form with the errors and what you typed. Square brackets make an array, and in PHP, an array can map keys to values too, with the double arrow, like a map in C++. The username's rules are strings, with any arguments after a colon. So between three comma sixteen is the login server's length for a name, and the regex is its letters, digits and underscores. The password gets the login server's lengths too, between eight and a hundred and twenty-eight, and confirmed. Confirmed means that a second field, the password confirmation, has to match it, as you can't see a password while you type it. And a typo would lock you out of your new account. The second array gives the regex rule a message in our own words, as Laravel's default says no more than that the format is invalid. And the third array calls the field name in Laravel's messages, so they say name, and not username. Then we go back up and add a comment over the validate: the login server's rules, in check username and check password. Then the insert, in a try, without checking first whether the name is free. Account create inserts the row, with the password from the form going into password hash, where the hashed cast turns it into a hash before it's stored. If someone has the name already, MySQL refuses the insert, and Laravel turns that error into a unique constraint violation exception, which we catch. Back sends you to the form, with an error on the name: that name is taken. And it keeps the username you typed, without the password, so the password isn't sent back to your browser. Over the try goes the reason: the unique key decides, as checking first and inserting after would let two people take that name in between. If the insert worked, Auth login logs the new account in, so you don't have to log in right after making it. Then regenerate gives the session a new id, and we redirect to the rankings, as the website's front page. Then logging in and out get a controller of their own, next to it. It uses the Request and Auth again, but not our model, as Auth finds the account for us. Login controller has three actions, starting with create, which shows the form. Store validates that the username and the password are there, as strings, without the lengths, as a wrong one fails the password check anyway. Then Auth attempt finds the account through the provider we set up, and checks the password with our hasher. If the attempt fails, it's back to the form with a single error, wrong name or password, like the login server's. And again, the username comes back, and the password doesn't. Once you're in, regenerate gives the session a new id, against what's called session fixation. Say someone got hold of your session id before you logged in, for example by setting the cookie on a shared computer. Without a new id, they'd be logged in along with you. Then redirect intended goes to the page you were trying to reach before you were asked to log in, or to the rankings. Destroy logs you out, where Auth logout clears the account from the session, and invalidate throws the session's data away. Then regenerate token replaces the session's token against forged requests, so the old one can't be used after you log out, and we redirect to the rankings. Then the routes, with use lines at the top for the two new controllers, while slash stays the rankings. Then a group, with the guest middleware, where middleware is code that runs on a request before it reaches our code. Guest lets you through when you're not logged in, and sends you to slash when you are, as there's no point in making an account or logging in again. Get register shows the form, and post register handles it, with throttle ten comma one. Throttle allows ten requests a minute from each address, and after that, Laravel answers with too many requests until the minute is up. Each attempt hashes a password, which takes sixty-four mebibytes and tens of milliseconds. So without a limit, a script could keep the website busy hashing, or make accounts by the thousand. Laravel counts the attempts in its cache, which is Redis. Then get login and post login go in the group too, for the login controller. The get is named login, as Laravel sends you to the route with that name when a page needs you logged in. The post gets the throttle too, as a login hashes a password like making an account does, and it slows down anyone guessing passwords. And post logout, outside the group, with the auth middleware, as you have to be logged in to log out. Back in register controller, as we don't check first, the catch is how a taken name gets its message, whether it went a day ago or a millisecond ago. Without the catch, MySQL's refusal would end in a server error page.
34:23 Then the pages for those controllers, starting with the register page, which goes in our layout and starts with a heading, Create an account. Then a form, with method post, that posts to slash register, where our route hands it to store. Inside it goes the first field: a label, with the word Name, and the input inside it. That way, the two belong together, so clicking the word puts you in the field, and Pico lays them out one under the other. The input's name is username, the field our controller validates, and its value is old, for that field, which puts back what you typed after a failed attempt. Autocomplete tells your browser what goes in the field, so it can suggest the name you use. And required makes the browser refuse to send the form with it empty, which saves a round trip. The server checks it again, as anyone can send a request without our form. Then, still on the input, at error adds aria invalid true when that field has an error. Screen readers announce it, and Pico draws the field in red. Under the input, at error again, this time with the message, in a small tag, which Pico shows under a field as its helper text. Then the second field, the password, whose input is of type password, so it's hidden as you type. Autocomplete new password tells a password manager that this is a new one, so it offers to make one up, and saves it. And it gets the red outline and the helper text under it, like the name does, for when the password is too short, or the two don't match. Then Password again, for the password confirmation, which is the field that confirmed compares. It has no error of its own, as a mismatch is reported on the password. And last, a button, Create account, which needs no type, as a button inside a form sends it. The login page is a shorter version of the register page, with the heading Log in, and a form that posts to slash login. Then the name, in its label, with its old value, like on the register page. It gets an error too, as that's where our controller's wrong name or password shows up, with the field in red and the message under it. Then the password, with autocomplete current password, so a password manager fills in the one it saved, instead of offering to make up a new one. There's no error under the password, as our controller puts its error on the name. And last, a button, Log in, which sends the form to the store action. In the layout's nav, the list on the right gets more links, where at auth shows what's inside it when you're logged in. First your username, from auth user, which is the Account model of whoever is logged in. And a form with a button, Log out, that posts to slash logout. Logging out is a post, and not a link, so that another site can't log you out with a link or an image. Then at else, for when you're not logged in, with links to Log in and Create account, which is how you get to the two pages we just wrote. To mark your row in the rankings, the ranking controller gets each player's account id too, along with the username and the experience. And in the rankings page, the name's cell gets an if. When the row's account id is the id of the account that's logged in, from auth id, the name goes in a mark tag, which Pico highlights. Otherwise, it's the plain name, as before, with no mark tag around it. Three equals signs compare the type as well as the value, and when you're not logged in, auth id is null, which matches no row.
37:51 Before we try the forms, back in the register page, inside the form, we add at csrf, which is Blade's directive for a hidden field with the session's CSRF token. CSRF stands for cross-site request forgery, where another site has a form that posts to ours. Your browser would send our cookies along with the post, so the request would look like it came from you. A current browser also sends a header, Sec-Fetch-Site, which says whether the request comes from a page on our own site, and Laravel thirteen lets a post through on that header alone. So our forms would work in this browser as they are. The token is for browsers and clients that don't send the header: Laravel turns away a post that has neither, and another site can't read our pages, so it can't get the token. The login form gets at csrf too, and so does the Log out form, in the layout's nav, as it posts as well.
38:46 Let's try it, with the website built and started again, like before. The Create account link in the nav takes us to the form. I try the name sam, which is taken, with a password, twice. The insert hits the unique key, and the form comes back with the name filled in, in red, and the message under it. So I go with maria instead, and type the password in both fields again. I'm in, as maria, with a Log out button in the nav. Maria isn't in the rankings, as she hasn't played yet. I log out, and log in as alice, whose hash libsodium made, like the login server makes them. Laravel reads that hash and lets me in, and alice's row is marked in the rankings.
39:31 And now the game, where the login server and the game server are at the bottom, as before, against the MySQL that the website writes to. In the client, I type maria, and the password I gave the website. I log in, and the login server checks the password against the hash Laravel made, and lets maria in. So you can play the game with an account made on the website, and the hashes work both ways.
Tests
39:57 Laravel comes with two example tests, and the unit one checks that true is true, so it goes. The feature one fetches slash, which would fail, as in tests, Laravel uses an empty SQLite database, in memory, without our players table, so it goes too. In their place, two tests of our own, with PHPUnit, which is PHP's test framework, and comes with Laravel. Unit tests go in tests slash Unit, and this one uses our Level, and PHPUnit's TestCase instead of Laravel's. Level test extends that TestCase, as the rule doesn't need the framework, so the test runs without starting Laravel. PHPUnit runs each public function whose name starts with test, so we can name it as a sentence: each level takes a hundred more than the last. Each assertion checks the value and its type, so a level that came back as a string would fail too. Zero experience is level one, and so is ninety-nine, a step short of the next level. A hundred is level two, which is the first edge. And four hundred and fifty is level three, which checks that the second level takes two hundred. Zero, ninety-nine and four hundred and fifty are the numbers from the C++ test, levels test dot c c, so both languages answer to one set of numbers. If the rule changes on one side, that side's test fails, which is our reminder to change the other one too. Feature tests go in tests slash Feature, and they start Laravel with our config, which this one needs, as it's about our hashing settings. Password test uses Hash, the facade for Laravel's hasher, and extends Laravel's TestCase, from tests. Its test is the website and the login server reading each other's hashes. The login server hashes with libsodium's crypto pwhash str, and PHP comes with libsodium, as its sodium extension. So the test can make a hash the way the login server does, without running the login server. First, theirs, where sodium crypto pwhash str hashes correct horse with libsodium's interactive limits. Then Hash check, which our login form goes through, has to accept the right password, and turn away a wrong one. The wrong one is there because a check that accepted anything would pass the first assertion. And needs rehash has to be false, so Laravel won't rewrite the game's hashes as people log in on the website. Then the other way round, ours, made with Hash make, like our register form makes them. And sodium crypto pwhash str verify, which the login server checks passwords with, has to accept it. In the website's Dockerfile, a test stage, between php and web, which starts from php, so it has our extensions and Composer. It copies the composer files and installs the libraries, with the ones for development this time, as PHPUnit and Pint are among them. Then the code, and a run line that writes the autoloader, and then runs Pint, Laravel's code formatter. Dash dash test makes Pint fail if it would change our code, like clang-format in our lint stage. Then PHPUnit runs the tests, so docker build dash dash target test fails if either of them fails. And the web stage doesn't start from this one, so a normal build of the website skips it. And in CI, a new step, test the website, runs that build, and the step that builds the images builds the website's image too, next to the servers'.
43:27 We build the test stage, and dash dash no cache filter test runs that stage again, even though the code hasn't changed since my last build. And dash dash progress plain prints its output, which grep cuts down to the results. Pint passes, and so do both tests, the levels and the hashes.
Docs
43:46 Laravel's own README goes, as it's about Laravel, with its logo and its links, while people looking at our repo want to know about the game. So the game's README gets a section on the website instead, and the section on code style moves up, next to building the game, as it's about the C++. The website's section says how to start it with compose, and where to open it. Then a few notes: who's in the rankings, the ten tries a minute, why there are no migrations, and the app key, with the command that makes a new one before the website goes public. And last, how to run the website's tests, with Pint, Laravel's formatter, in the test stage.
Wrap-up
44:24 So, today our game got a website: a Laravel app, which we made with Composer in Docker, so we didn't need PHP on our machine. It runs on FrankenPHP, a web server with PHP built in, as one more service in our compose stack, with its sessions and its rate limits in Redis. Its front page ranks the players, with a join on the game server's tables, and the levels worked out in PHP, like the game server does. Pico CSS styles the plain HTML, from a CDN, so we wrote no CSS, and there's no npm build. The tables are still the ones in db slash schema dot sql, so the website has no migrations of its own. You can create an account, log in and log out, with Laravel's validation, CSRF tokens and rate limiting. It hashes passwords with the login server's Argon2id settings, so accounts work both ways, and Laravel leaves the game's hashes as they are. And two tests run in CI, one for the levels, and one that checks the website and the login server read each other's hashes.
Playing it
46:33 Let me know in the comments what you'd like to see next, and if you want to follow along, subscribe. I hope you find this useful, and thanks for watching.