On Agoralis, a role-play Minecraft server, a player can buy a house from their phone. They tap "Buy" in the web app, the money leaves their bank account, and in the same request the region is theirs in the game: they become owner, the seller gets an in-game message. The same goes for jobs, XP and permissions. None of this goes through a database the game reads. Every action is a console command sent over RCON.

This article shows how we wired that up in Laravel: what an RCON packet looks like on the wire, how authentication works, how PHP sockets behave when the server does not answer, and the error-handling choices we made (and would make differently today).

TL;DR

  • RCON is a tiny TCP protocol: each packet is size | id | type | body\0\0, all integers 32-bit little-endian. PHP's pack('VV', ...) and fsockopen() are all you need.
  • Authenticate with a type-3 packet carrying the password; the server answers with type 2 and your request id, or id -1 if the password is wrong.
  • There are two timeouts, not one: the connect timeout passed to fsockopen() and the read timeout set with stream_set_timeout(). Set both, or a silent server freezes your PHP worker.
  • Wrap every call in one service class and decide, per context, what "server down" means: an empty widget on the home page, a 503 on a game action, a log line in a cron job.
  • Never send user input to the console unfiltered: validate player names against ^[a-zA-Z0-9_]{2,16}$ before they reach a command.

The context

Agoralis is an economic role-play Minecraft server: players get a job, earn in-game money, rent or buy land, take out loans. The gameplay is written in Java by the server team. We built everything that runs in the browser with Laravel, Livewire and Filament: the player web app (bank, properties, jobs, XP) and the moderation back office. Development started in January 2024; the server opened to the public in August 2025. The full project story (timeline, constraints, team) is here.

The constraints that shaped the integration:

  • The game server was being built at the same time as the web app. We could not wait for a stable API on the Java side.
  • The server team were volunteers. Anything we asked them to build cost weeks.
  • Two-person web team. One backend developer owned the whole sync layer.
  • Every web action had to have an in-game effect, and mostly right away: a player buying XP expects to see it before they close their phone.

We looked at three ways to push actions from the web app to the game:

Option What it needs on the game side Why we did or didn't pick it
HTTP API exposed by a plugin An embedded HTTP server, routes, auth, deployment Dropped: too slow to build for our deadline, and impractical with a game server still changing every week
Message queue (Redis pub/sub) A Redis client in the plugin, a consumer, retry logic Dropped for the same reasons: not doable within the deadline, and one more piece of infrastructure to run
RCON Nothing: it ships with every Minecraft server Chosen. Native, documented, works the day you set a password

RCON only covers one direction: web to game. For the other direction (the game telling the web app that a player earned money, for example), the server team's plugin calls a small Laravel API restricted to the game host's IP range. That half is out of scope here.

One more design choice made RCON workable: the server team wrapped every action we needed in a single custom command, webapi. Instead of sending raw op-level commands, the web app sends webapi perms add …, webapi rg addowner …, webapi exp take …, webapi tell …. The plugin relays them to the permissions, region and jobs plugins. That gave both teams one documented contract to agree on.

Step 1: understand the packet before trusting a library

We did not write our own client. We used thedudeguy/rcon, a single 200-line class. We did read all of it first, because when the game server misbehaves, you need to know what is on the wire. Minecraft implements the Source RCON protocol. Every packet, in both directions, has the same layout:

Field Type Notes
Size int32, little-endian Length of everything after this field
Request ID int32, little-endian Chosen by the client, echoed by the server
Type int32, little-endian 3 = login, 2 = command (or auth response), 0 = command response
Body ASCII, null-terminated Password or command
Padding 1 byte \x00 Empty string terminator

Here is how the library builds a packet. V in pack() means "unsigned 32-bit, little-endian", which matches the protocol for the positive ids and types used here.

private function writePacket($packetId, $packetType, $packetBody) { // id + type, then the body and two null bytes $packet = pack('VV', $packetId, $packetType); $packet = $packet . $packetBody . "\x00"; $packet = $packet . "\x00"; // the size prefix does not count itself $packet = pack('V', strlen($packet)) . $packet; fwrite($this->socket, $packet, strlen($packet)); } 

The detail that matters: the size field excludes its own 4 bytes. Get that wrong and the server waits for bytes that never come, which looks exactly like a timeout.

Reading is the mirror image: 4 bytes for the size, then exactly that many bytes.

private function readPacket() { $sizeData = fread($this->socket, 4); $size = unpack('V1size', $sizeData)['size']; // Responses over 4096 bytes are split across several packets. // The library does not handle that case. $packetData = fread($this->socket, $size); return unpack('V1id/V1type/a*body', $packetData); } 

The comment is the library's own warning, and it is a real limit: a command whose output exceeds 4 KB comes back truncated. None of our webapi commands returned that much, so we accepted it.

Step 2: authenticate on every connection

RCON authentication is one round trip. The client sends a type-3 packet with the password as its body. The server answers with a type-2 packet. If the id matches the one you sent, you are in. If the id is -1, the password is wrong.

const PACKET_AUTHORIZE = 5; // our request id for the login packet const SERVERDATA_AUTH = 3; const SERVERDATA_AUTH_RESPONSE = 2; private function authorize() { $this->writePacket(self::PACKET_AUTHORIZE, self::SERVERDATA_AUTH, $this->password); $response = $this->readPacket(); if ($response['type'] == self::SERVERDATA_AUTH_RESPONSE && $response['id'] == self::PACKET_AUTHORIZE) { $this->authorized = true; return true; } $this->disconnect(); return false; } 

Note what happens on a wrong password: no exception, just false. Then sendCommand() sees isConnected() === false and also returns false. If your code only catches exceptions, a bad password after a config change fails silently. We learned to treat false as an error too, and our production code still does not: see "What breaks when the server stops answering".

Step 3: open the socket with two timeouts

The connection itself is a plain fsockopen():

public function connect() { $this->socket = fsockopen($this->host, $this->port, $errno, $errstr, $this->timeout); if (!$this->socket) { $this->lastResponse = $errstr; return false; } // read timeout: 3 seconds, hardcoded in the library stream_set_timeout($this->socket, 3, 0); return $this->authorize(); } 

There are two different timeouts here, and they protect against two different failures:

Failure What PHP does Which timeout saves you
Server host down, port closed fsockopen() fails fast (connection refused) None needed
Firewall drops packets, host unreachable fsockopen() blocks until the timeout $timeout passed to fsockopen() (we use 5 s, from .env)
TCP connects, but the Minecraft process is frozen (long GC pause, world save, crash in progress) fread() blocks waiting for bytes stream_set_timeout() (3 s in the library)

The third case is the nasty one. Without a read timeout, fread() waits up to PHP's default_socket_timeout (60 s by default), and your FPM worker is stuck the whole time. With it, fread() returns an empty string after 3 seconds, and unpack() on that empty string raises a warning. Laravel converts warnings into ErrorException, so the failure surfaces as an exception you can catch. Our whole error handling relies on that conversion. The same goes for a refused or unreachable host: fsockopen()\ emits a warning before returning false\, so in Laravel you get an exception, not a false\.

Step 4: one service class for the whole app

All game actions go through App\Service\Server. This is the version running in production today:

namespace App\Service; use Exception; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Route; use Thedudeguy\Rcon; class Server { public ?Rcon $rcon = null; public function __construct() { $this->rcon = new Rcon( config('app.rconHost'), config('app.rconPort'), config('app.rconPassword'), config('app.rconTimeout'), ); } public function sendCommand($command) { try { $this->rcon->connect(); $response = $this->rcon->sendCommand($command); $this->rcon->disconnect(); return $response; } catch (Exception $e) { Log::error('Connection error' . $e->getMessage()); // The home page must render even when the game is down if (Route::current()->uri === '/') { return null; } abort(503); } } } 

Each call opens a connection, authenticates, sends one command and closes. That costs a TCP handshake and an auth round trip per command, but it means no connection state to manage between Livewire requests, and a server restart never leaves us holding a dead socket. For our volume (a handful of commands per player action), it was the right trade.

What breaks when the server stops answering

The web app was built while the game server was still under construction, so "server down" was a normal state for months. In January 2025, before the public opening, we added the offline handling you saw in Server::sendCommand(). There was no incident behind it: we listed the screens that call RCON and asked what each one should do when the game is unreachable.

The home page must never depend on the game

The public home page shows how many players are online, using the vanilla glist command. If the game is down, the marketing site should still load:

public function index() { $home = Page::where('is_home', 1)->first(); $server = new Server(); $nbOnline = $server->sendCommand('glist'); if ($nbOnline !== null) { $nbOnline = explode(' ', $nbOnline)[0]; } // ... } 

That is why Server returns null on the / route instead of aborting. The Blade view checks @if ($nbOnline !== null) and shows an "Offline" badge otherwise.

Game actions fail loudly with a 503

Everywhere else, a lost connection calls abort(503) and the player sees a custom 503 page. Our reasoning: a player who buys a property while the game is down should not see a success message for something that did not happen in game.

Where the abort() lands depends on the caller, and that surprised us when we re-read the code for this article. In the XP deposit, the RCON call and the database update share a try:

try { $server->sendCommand('webapi exp take ' . auth()->user()->mojang_username . ' ' . $amount); $this->xpAccount->update([ 'balance' => $this->xpAccount->balance + $amount, ]); // ... activity log, success notification } catch (\Exception $e) { Log::error($e->getMessage()); // ... "An error occurred during the deposit" notification } 

abort(503) throws an HttpException, which extends \Exception. So here the 503 never reaches the browser: it is caught, the balance is not updated, and the player gets a Filament error toast. That is the behaviour we want, but it works because of the order of the lines, not because anyone designed it.

The order of operations is the real risk

The property purchase does the opposite. It writes the bank transfer first, then sends five RCON commands:

WireTransfer::create([/* buyer -> seller, property price */]); $server = new Server(); $server->sendCommand('webapi rg removemember ' . $property->slug . ' -a'); $server->sendCommand('webapi rg removeowner ' . $property->slug . ' ' . $seller); $server->sendCommand('webapi rg addowner ' . $property->slug . ' ' . $buyer); $server->sendCommand('webapi mail ' . $seller . ' Your property has been sold.'); $server->sendCommand('webapi tell ' . $seller . ' Your property has been sold.'); 

If the server goes down between the transfer and the third command, the money has moved and the region has not changed hands. Each new Server() call is also a separate TCP connection plus auth, so this action opens five connections in a row. It never happened in production, but nothing in the code prevents it.

What we would do today: send the commands that matter first, write to the database only once they succeed, and push the notifications (mail, tell) to a queued job that can retry.

Console commands have no route

The / check uses Route::current(). In an Artisan command (the daily rent, tax and loan payouts are Artisan commands, run by a cron the game server admin set up himself rather than by Laravel's scheduler, because he wanted to keep control over when money moves), there is no current route, so Route::current() returns null and ->uri throws an Error. That Error is not an Exception, so it escapes the catch and stops the whole payout loop. In the tax command, RCON is only called to warn a player who cannot pay; if the game is down at that moment, every player after them in the loop is skipped for the day. A nullsafe Route::current()?->uri and a runningInConsole() branch would fix it.

A wrong password looks like success

As seen in step 2, a failed auth returns false with no exception. sendCommand() then returns false, Server passes it through, and callers that only check for null carry on. A one-line guard in Server would turn it into a logged error.

Summary of failure modes

Situation What the library does What the player sees today
Host unreachable Warning in fsockopen() after 5 s, converted to ErrorException 503 page, or offline badge on the home page
Process frozen, socket open fread() returns empty after 3 s, warning in unpack() Same as above
Wrong RCON password Returns false, no exception Nothing: the action "succeeds" on the web side
Player offline (for exp bal) Normal response, no number in it "You must be connected to see your XP" message
Output over 4 KB Truncated body Not hit with our commands
Error in a scheduled command Route::current() is null Payout loop stops

Wiring it into Laravel

The connection settings live in .env and are read through config(), so they survive php artisan config:cache:

RCON_HOST=rcon.example.com RCON_PORT=25575 RCON_PASSWORD=change-me RCON_TIMEOUT=5 
// config/app.php 'rconHost' => env('RCON_HOST'), 'rconPort' => env('RCON_PORT'), 'rconPassword' => env('RCON_PASSWORD'), 'rconTimeout' => env('RCON_TIMEOUT'), 

This was not the first version. The first Server class had the host, port and timeout hardcoded and read the password with env() directly. That breaks as soon as the config is cached: env() returns null outside config files. We fixed it in two steps: in May 2025 the hardcoded values moved to .env, and in March 2026, months after the public opening, the env() calls moved into config/app.php.

On the server side, the matching settings are in server.properties: enable-rcon=true, rcon.port and rcon.password.

In components, a call looks like this. Here, a Livewire component in the registration flow sends a one-time code to the player in game, to prove they own the Minecraft account they claim:

public function validatePseudo() { // Minecraft usernames: 2 to 16 characters, letters, digits and underscore if (!preg_match('/^[a-zA-Z0-9_]{2,16}$/D', $this->pseudo)) { $this->displayPseudoInvalid = true; return; } if ($this->checkIfPseudoExist()) { $this->displayPseudoAlreadyTaken = true; return; } $validationCode = Str::random(4); session()->put('validation_code', $validationCode); $server = new Server(); $server->sendCommand('webapi tell ' . $this->pseudo . ' Your validation code is: ' . $validationCode); } 

The regex is not cosmetic: $this->pseudo is typed by an anonymous visitor and ends up in a server console command. More on that in the security section.

We call new Server() directly in 18 files: Livewire components, Filament actions, model observers and Artisan commands. It works, but it is the main thing we would change. Binding Server in the container ($this->app->singleton(Server::class)) and injecting it would let tests swap it for a fake in one line, and would let one request reuse a single connection for several commands.

Testing

The hard part of testing this integration is that the other side was still being built for most of the project. We did not mock the protocol. We set up a complete test Minecraft server, running the server team's webapi plugin, and pointed the web app at it to test every action in real conditions. The list of webapi commands, kept in the web app documentation, was the contract both teams tested against.

The repository has no automated test around Server, and the new Server() calls are the reason: there is no seam to swap it out. If we were starting again, we would bind it in the container and write feature tests like this one:

// Not in the project today: what the container binding would allow public function test_buying_a_property_gives_the_region_to_the_buyer(): void { $server = Mockery::mock(Server::class); $server->shouldReceive('sendCommand') ->with('webapi rg addowner villa-12 Buyer_01') ->once(); // ... other expected commands $this->app->instance(Server::class, $server); // ... run the Livewire purchase action and assert on the bank balances } 

For the protocol itself, a 30-line fake RCON server written with stream_socket_server() is enough to test the timeout cases: one that never answers, one that answers with the wrong id, one that closes the socket mid-packet.

Security

RCON gives full console access to whoever holds the password. Three rules we followed:

  1. The password never leaves .env. Not in the code, not in comments, not in logs, not in the back office. Grep your history for it: an old hardcoded value survives in git long after you moved it.
  2. The RCON port is not public. RCON sends the password in clear text over TCP; there is no TLS. Only the web server can reach the port: firewall rules on the game host plus an IP allowlist.
  3. User input never reaches the console raw. Player names are validated against the Minecraft format (^[a-zA-Z0-9_]{2,16}$) before being used in a command. A name with a space in it would otherwise add arguments to a command that runs with console rights.

The webapi wrapper helps here too. The web app never sends vanilla admin commands (except glist), only the subcommands the server team chose to expose. If the RCON password leaked, it would still grant full console access, but a bug in the web app can only trigger actions on that short list.

Wrapping up

  • Read the library before trusting it. thedudeguy/rcon is 200 lines; knowing that it returns false on a bad password and caps responses at 4 KB saved us from wrong assumptions.
  • Set both timeouts. The connect timeout handles a dead host; the read timeout handles a frozen game. The second one is the one that keeps PHP workers alive.
  • Decide what "server down" means per screen. A public page degrades, a game action fails visibly, a cron job logs and carries on to the next player.
  • Order matters more than error handling. Send the game command that matters before writing to the database, and queue the notifications.