<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Hugo Bachmann</title>
    <description>The latest articles on DEV Community by Hugo Bachmann (@hugobachmann).</description>
    <link>https://dev.to/hugobachmann</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4200744%2F888fa78e-e094-4e20-ba59-02f18475ca16.png</url>
      <title>DEV Community: Hugo Bachmann</title>
      <link>https://dev.to/hugobachmann</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hugobachmann"/>
    <language>en</language>
    <item>
      <title>Controlling a Minecraft server from Laravel with RCON</title>
      <dc:creator>Hugo Bachmann</dc:creator>
      <pubDate>Mon, 09 Nov 2026 13:13:49 +0000</pubDate>
      <link>https://dev.to/hugobachmann/controlling-a-minecraft-server-from-laravel-with-rcon-221d</link>
      <guid>https://dev.to/hugobachmann/controlling-a-minecraft-server-from-laravel-with-rcon-221d</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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).&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;RCON is a tiny TCP protocol: each packet is &lt;code&gt;size | id | type | body\0\0&lt;/code&gt;, all integers 32-bit little-endian. PHP's &lt;code&gt;pack('VV', ...)&lt;/code&gt; and &lt;code&gt;fsockopen()&lt;/code&gt; are all you need.&lt;/li&gt;
&lt;li&gt;Authenticate with a type-3 packet carrying the password; the server answers with type 2 and your request id, or id &lt;code&gt;-1&lt;/code&gt; if the password is wrong.&lt;/li&gt;
&lt;li&gt;There are two timeouts, not one: the connect timeout passed to &lt;code&gt;fsockopen()&lt;/code&gt; and the read timeout set with &lt;code&gt;stream_set_timeout()&lt;/code&gt;. Set both, or a silent server freezes your PHP worker.&lt;/li&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;li&gt;Never send user input to the console unfiltered: validate player names against &lt;code&gt;^[a-zA-Z0-9_]{2,16}$&lt;/code&gt; before they reach a command.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The context
&lt;/h2&gt;

&lt;p&gt;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 &lt;a href="https://humatis.fr/cas-clients/agoralis" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The constraints that shaped the integration:&lt;/p&gt;

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

&lt;p&gt;We looked at three ways to push actions from the web app to the game:&lt;/p&gt;

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

&lt;p&gt;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.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Step 1: understand the packet before trusting a library
&lt;/h2&gt;

&lt;p&gt;We did not write our own client. We used &lt;a href="https://github.com/thedudeguy/PHP-Minecraft-Rcon" rel="noopener noreferrer"&gt;&lt;code&gt;thedudeguy/rcon&lt;/code&gt;&lt;/a&gt;, 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 &lt;a href="https://developer.valvesoftware.com/wiki/Source_RCON_Protocol" rel="noopener noreferrer"&gt;Source RCON protocol&lt;/a&gt;. Every packet, in both directions, has the same layout:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Size&lt;/td&gt;
&lt;td&gt;int32, little-endian&lt;/td&gt;
&lt;td&gt;Length of everything after this field&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Request ID&lt;/td&gt;
&lt;td&gt;int32, little-endian&lt;/td&gt;
&lt;td&gt;Chosen by the client, echoed by the server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Type&lt;/td&gt;
&lt;td&gt;int32, little-endian&lt;/td&gt;
&lt;td&gt;3 = login, 2 = command (or auth response), 0 = command response&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Body&lt;/td&gt;
&lt;td&gt;ASCII, null-terminated&lt;/td&gt;
&lt;td&gt;Password or command&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Padding&lt;/td&gt;
&lt;td&gt;1 byte &lt;code&gt;\x00&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Empty string terminator&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;writePacket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$packetId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$packetType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$packetBody&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// id + type, then the body and two null bytes&lt;/span&gt;
    &lt;span class="nv"&gt;$packet&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;pack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'VV'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$packetId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$packetType&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$packet&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$packet&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$packetBody&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\x00&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nv"&gt;$packet&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$packet&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\x00&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// the size prefix does not count itself&lt;/span&gt;
    &lt;span class="nv"&gt;$packet&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;pack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'V'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;strlen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$packet&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$packet&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="nb"&gt;fwrite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$packet&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;strlen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$packet&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Reading is the mirror image: 4 bytes for the size, then exactly that many bytes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;readPacket&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$sizeData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;fread&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$size&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;unpack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'V1size'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$sizeData&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="s1"&gt;'size'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

    &lt;span class="c1"&gt;// Responses over 4096 bytes are split across several packets.&lt;/span&gt;
    &lt;span class="c1"&gt;// The library does not handle that case.&lt;/span&gt;
    &lt;span class="nv"&gt;$packetData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;fread&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$size&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;unpack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'V1id/V1type/a*body'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$packetData&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;code&gt;webapi&lt;/code&gt; commands returned that much, so we accepted it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: authenticate on every connection
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;-1&lt;/code&gt;, the password is wrong.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="no"&gt;PACKET_AUTHORIZE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;       &lt;span class="c1"&gt;// our request id for the login packet&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="no"&gt;SERVERDATA_AUTH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="no"&gt;SERVERDATA_AUTH_RESPONSE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;authorize&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;writePacket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;PACKET_AUTHORIZE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;SERVERDATA_AUTH&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;readPacket&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'type'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;SERVERDATA_AUTH_RESPONSE&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'id'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;PACKET_AUTHORIZE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;authorized&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;disconnect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;h2&gt;
  
  
  Step 3: open the socket with two timeouts
&lt;/h2&gt;

&lt;p&gt;The connection itself is a plain &lt;code&gt;fsockopen()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;socket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;fsockopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$errno&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$errstr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;lastResponse&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$errstr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// read timeout: 3 seconds, hardcoded in the library&lt;/span&gt;
    &lt;span class="nb"&gt;stream_set_timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;authorize&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are two different timeouts here, and they protect against two different failures:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Failure&lt;/th&gt;
&lt;th&gt;What PHP does&lt;/th&gt;
&lt;th&gt;Which timeout saves you&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Server host down, port closed&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;fsockopen()&lt;/code&gt; fails fast (connection refused)&lt;/td&gt;
&lt;td&gt;None needed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Firewall drops packets, host unreachable&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;fsockopen()&lt;/code&gt; blocks until the timeout&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;$timeout&lt;/code&gt; passed to &lt;code&gt;fsockopen()&lt;/code&gt; (we use 5 s, from &lt;code&gt;.env&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TCP connects, but the Minecraft process is frozen (long GC pause, world save, crash in progress)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;fread()&lt;/code&gt; blocks waiting for bytes&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;stream_set_timeout()&lt;/code&gt; (3 s in the library)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The third case is the nasty one. Without a read timeout, &lt;code&gt;fread()&lt;/code&gt; waits up to PHP's &lt;code&gt;default_socket_timeout&lt;/code&gt; (60 s by default), and your FPM worker is stuck the whole time. With it, &lt;code&gt;fread()&lt;/code&gt; returns an empty string after 3 seconds, and &lt;code&gt;unpack()&lt;/code&gt; on that empty string raises a warning. Laravel converts warnings into &lt;code&gt;ErrorException&lt;/code&gt;, 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: &lt;code&gt;fsockopen()\&lt;/code&gt; emits a warning before returning &lt;code&gt;false\&lt;/code&gt;, so in Laravel you get an exception, not a &lt;code&gt;false\&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: one service class for the whole app
&lt;/h2&gt;

&lt;p&gt;All game actions go through &lt;code&gt;App\Service\Server&lt;/code&gt;. This is the version running in production today:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Service&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Support\Facades\Log&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Support\Facades\Route&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Thedudeguy\Rcon&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Server&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;?Rcon&lt;/span&gt; &lt;span class="nv"&gt;$rcon&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;rcon&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Rcon&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.rconHost'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.rconPort'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.rconPassword'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.rconTimeout'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;sendCommand&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$command&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;rcon&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
            &lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;rcon&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;sendCommand&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$command&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;rcon&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;disconnect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nc"&gt;Log&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Connection error'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getMessage&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

            &lt;span class="c1"&gt;// The home page must render even when the game is down&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;current&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;uri&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'/'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;

            &lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;503&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What breaks when the server stops answering
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;Server::sendCommand()&lt;/code&gt;. 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  The home page must never depend on the game
&lt;/h3&gt;

&lt;p&gt;The public home page shows how many players are online, using the vanilla &lt;code&gt;glist&lt;/code&gt; command. If the game is down, the marketing site should still load:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$home&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Page&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'is_home'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="nv"&gt;$server&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Server&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$nbOnline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$server&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;sendCommand&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'glist'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$nbOnline&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$nbOnline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;explode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;' '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$nbOnline&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;h3&gt;
  
  
  Game actions fail loudly with a 503
&lt;/h3&gt;

&lt;p&gt;Everywhere else, a lost connection calls &lt;code&gt;abort(503)&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;Where the &lt;code&gt;abort()&lt;/code&gt; 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 &lt;code&gt;try&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$server&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;sendCommand&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'webapi exp take '&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nf"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;user&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;mojang_username&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$amount&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;xpAccount&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="s1"&gt;'balance'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;xpAccount&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nv"&gt;$amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="c1"&gt;// ... activity log, success notification&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;\Exception&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Log&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getMessage&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="c1"&gt;// ... "An error occurred during the deposit" notification&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;abort(503)&lt;/code&gt; throws an &lt;code&gt;HttpException&lt;/code&gt;, which extends &lt;code&gt;\Exception&lt;/code&gt;. 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  The order of operations is the real risk
&lt;/h3&gt;

&lt;p&gt;The property purchase does the opposite. It writes the bank transfer first, then sends five RCON commands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;WireTransfer&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="cm"&gt;/* buyer -&amp;gt; seller, property price */&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="nv"&gt;$server&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Server&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nv"&gt;$server&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;sendCommand&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'webapi rg removemember '&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$property&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;' -a'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$server&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;sendCommand&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'webapi rg removeowner '&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$property&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$seller&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$server&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;sendCommand&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'webapi rg addowner '&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$property&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$buyer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$server&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;sendCommand&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'webapi mail '&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$seller&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;' Your property has been sold.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$server&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;sendCommand&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'webapi tell '&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$seller&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;' Your property has been sold.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the server goes down between the transfer and the third command, the money has moved and the region has not changed hands. Each &lt;code&gt;new Server()&lt;/code&gt; 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.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Console commands have no route
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;/&lt;/code&gt; check uses &lt;code&gt;Route::current()&lt;/code&gt;. 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 &lt;code&gt;Route::current()&lt;/code&gt; returns &lt;code&gt;null&lt;/code&gt; and &lt;code&gt;-&amp;gt;uri&lt;/code&gt; throws an &lt;code&gt;Error&lt;/code&gt;. That &lt;code&gt;Error&lt;/code&gt; is not an &lt;code&gt;Exception&lt;/code&gt;, so it escapes the &lt;code&gt;catch&lt;/code&gt; 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 &lt;code&gt;Route::current()?-&amp;gt;uri&lt;/code&gt; and a &lt;code&gt;runningInConsole()&lt;/code&gt; branch would fix it.&lt;/p&gt;

&lt;h3&gt;
  
  
  A wrong password looks like success
&lt;/h3&gt;

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

&lt;h3&gt;
  
  
  Summary of failure modes
&lt;/h3&gt;

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

&lt;h2&gt;
  
  
  Wiring it into Laravel
&lt;/h2&gt;

&lt;p&gt;The connection settings live in &lt;code&gt;.env&lt;/code&gt; and are read through &lt;code&gt;config()&lt;/code&gt;, so they survive &lt;code&gt;php artisan config:cache&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;RCON_HOST=rcon.example.com
RCON_PORT=25575
RCON_PASSWORD=change-me
RCON_TIMEOUT=5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// config/app.php&lt;/span&gt;
&lt;span class="s1"&gt;'rconHost'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'RCON_HOST'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="s1"&gt;'rconPort'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'RCON_PORT'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="s1"&gt;'rconPassword'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'RCON_PASSWORD'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="s1"&gt;'rconTimeout'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'RCON_TIMEOUT'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;On the server side, the matching settings are in &lt;code&gt;server.properties&lt;/code&gt;: &lt;code&gt;enable-rcon=true&lt;/code&gt;, &lt;code&gt;rcon.port&lt;/code&gt; and &lt;code&gt;rcon.password&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;validatePseudo&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Minecraft usernames: 2 to 16 characters, letters, digits and underscore&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;preg_match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/^[a-zA-Z0-9_]{2,16}$/D'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;pseudo&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;displayPseudoInvalid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;checkIfPseudoExist&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;displayPseudoAlreadyTaken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$validationCode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Str&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;session&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'validation_code'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$validationCode&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nv"&gt;$server&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Server&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$server&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;sendCommand&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'webapi tell '&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;pseudo&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;' Your validation code is: '&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$validationCode&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The regex is not cosmetic: &lt;code&gt;$this-&amp;gt;pseudo&lt;/code&gt; is typed by an anonymous visitor and ends up in a server console command. More on that in the security section.&lt;/p&gt;

&lt;p&gt;We call &lt;code&gt;new Server()&lt;/code&gt; 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 &lt;code&gt;Server&lt;/code&gt; in the container (&lt;code&gt;$this-&amp;gt;app-&amp;gt;singleton(Server::class)&lt;/code&gt;) 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;webapi&lt;/code&gt; plugin, and pointed the web app at it to test every action in real conditions. The list of &lt;code&gt;webapi&lt;/code&gt; commands, kept in the web app documentation, was the contract both teams tested against.&lt;/p&gt;

&lt;p&gt;The repository has no automated test around &lt;code&gt;Server&lt;/code&gt;, and the &lt;code&gt;new Server()&lt;/code&gt; 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Not in the project today: what the container binding would allow&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;test_buying_a_property_gives_the_region_to_the_buyer&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$server&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Mockery&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;mock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Server&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$server&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;shouldReceive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sendCommand'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'webapi rg addowner villa-12 Buyer_01'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;once&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="c1"&gt;// ... other expected commands&lt;/span&gt;

    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;instance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Server&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$server&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// ... run the Livewire purchase action and assert on the bank balances&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the protocol itself, a 30-line fake RCON server written with &lt;code&gt;stream_socket_server()&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security
&lt;/h2&gt;

&lt;p&gt;RCON gives full console access to whoever holds the password. Three rules we followed:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The password never leaves &lt;code&gt;.env&lt;/code&gt;.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The RCON port is not public.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;User input never reaches the console raw.&lt;/strong&gt; Player names are validated against the Minecraft format (&lt;code&gt;^[a-zA-Z0-9_]{2,16}$&lt;/code&gt;) 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.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The &lt;code&gt;webapi&lt;/code&gt; wrapper helps here too. The web app never sends vanilla admin commands (except &lt;code&gt;glist&lt;/code&gt;), 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Read the library before trusting it.&lt;/strong&gt; &lt;code&gt;thedudeguy/rcon&lt;/code&gt; is 200 lines; knowing that it returns &lt;code&gt;false&lt;/code&gt; on a bad password and caps responses at 4 KB saved us from wrong assumptions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set both timeouts.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decide what "server down" means per screen.&lt;/strong&gt; A public page degrades, a game action fails visibly, a cron job logs and carries on to the next player.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Order matters more than error handling.&lt;/strong&gt; Send the game command that matters before writing to the database, and queue the notifications.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>minecraft</category>
      <category>sockets</category>
    </item>
  </channel>
</rss>
