TCP Connection Practices in PHP Applications

Mert Simsek
Beyn Technology
Published in
13 min readNov 4, 2023

I/O operations are indispensable for modern software. In this document, we will answer questions such as how we can optimize network I/O operation and what we should pay attention to. If we give an example of Network I/O operations; We can think of HTTP requests to an external web service, a database connection, or connections to external system services such as Redis, Elasticsearch, or message queuing systems. In particular, our applications exchange data through TCP connections, and we will see in which cases and how these connections are created or closed. As we said in the title, we will see these specifically in PHP runtime.

Since there are many resources on the internet about topics such as TCP/IP vs OSI Model, TCP 3-Way Handshake Process, TCP vs UDP, I continue by assuming that you know these topics.

TCP Connection States

TCP connection states refer to states that represent their different phases. These states show how the connection between two points of communication is managed and when it goes through which stages. These states show the lifecycle of the TCP connection and indicate where the connection is at each stage. These states are important for network debugging and network traffic monitoring.

  • ESTABLISHED: This represents the situation where the TCP connection between two devices is successfully established and data transmission occurs. Communicating devices can exchange data.
  • CLOSE-WAIT: This represents a phase where the local device sends data and the remote device waits for the connection to be closed. In other words, the local device made a shutdown request after sending the data and is now waiting for the other party to respond to the shutdown request.
  • TIME-WAIT: This represents a phase in which the connection is waited for a period of time after being closed. The purpose of this wait is to make sure that the last packets from the connection are still being transmitted. During this time, no new connection can be established.
  • LISTEN: Represents the state where a server application waits for incoming connections. The server listens on a specific port and accepts incoming connections.
  • SYN-SENT: This represents the stage where the local device sends a SYN packet to initiate a connection. The other party is expected to respond to this SYN packet with SYN-ACK.
  • SYN-RECEIVED: This represents the stage where the local device sends a SYN packet and the other party responds with SYN-ACK. Now the local device must complete the connection by sending an ACK packet.
  • CLOSED: Represents the stage where the connection is completely closed and cannot be used for communication. This can happen before the connection is initiated or after it is closed.

HTTP Requests in PHP

What happens when making HTTP requests in PHP applications? In the example below, a total of 20 requests are made to a remote machine using curl_multi_init and curl_multi_add_handle. As an HTTP verb, the POST method is used.

<?php
try {
$url = 'http://server_web_container/bet';

$data = [
'action' => 'bet',
'amount' => 0.10
];

$totalRequests = 20;

$mh = curl_multi_init();
$ch = [];

for ($i = 0; $i < $totalRequests; $i++) {
$ch[$i] = curl_init($url);
curl_setopt($ch[$i], CURLOPT_POST, 1);
curl_setopt($ch[$i], CURLOPT_POSTFIELDS, http_build_query($data));
curl_setopt($ch[$i], CURLOPT_RETURNTRANSFER, true);
curl_multi_add_handle($mh, $ch[$i]);
}

do {
curl_multi_exec($mh, $running);
} while ($running > 0);

for ($i = 0; $i < $totalRequests; $i++) {
curl_multi_remove_handle($mh, $ch[$i]);
curl_close($ch[$i]);
}

curl_multi_close($mh);

echo "$totalRequests requests are handled totally.";
} catch (\Exception $e) {
echo $e->getMessage();
}

Since HTTP/1.1 is used by default, we have another invisible line in the code as follows.

curl_setopt($ch[$i], CURLOPT_HTTPHEADER, ["Connection: keep-alive"]);

Since the same options are available in Guzzle, Symfony or Laravel HTTP clients, there is no need to show examples of all of them one by one, so we are proceeding specifically with curl. You can see how you can add the same options in the relevant clients’ documentation.

Connections between a client and server using HTTP/1.1 automatically operate in keep-alive mode. In this way, we prevent connections from closing completely after data exchange, and we prevent new connections from being opened when we make a request to the same machine again because this costs time and resources. It saves time and resources because it prevents the process of establishing new connections. Rather than establishing a separate connection for each request, reusing the same connection is faster and uses server resources more efficiently.

Additionally, when making a request as a client, we can add timeout and max header values and thus tell the other party how many seconds this TCP connection will remain alive or the maximum number of times it can be in this period, but determining the “keep-alive” time is usually a configuration setting on the server side and is not a direct response. It is not recommended to add it as an HTTP request header. However, some servers may allow clients to set the duration and number of “keep-alive” using request headers. Therefore, it is not always safe to use these values because the other party may not take these values into account.

After the application is run, 20 TCP connections are opened and their status is ESTABLISHED. Currently, data is being exchanged between our local machine and the remote server.

When the relevant data exchange is completed on the local machine and 60 seconds have passed (depending on the configuration, but generally 60 seconds), we see that the status of the connections changes to CLOSE-WAIT. We now have the answers from the remote machine. If a request is made to the same IP:PORT address, curl will use these connections automatically. Then, we can easily say that if there is a remote machine that we constantly access from within our application, using “Connection: keep-alive” will be efficient and performant.

Another thing we should not forget is that if we use services where we can manage PHP Processes such as “supervisord, Swoole, Laravel Octane, Symfony Messenger” when we create more than one PHP Process, we should keep in mind that these TCP connections will be opened per PHP Process, for example, the same PHP If we run the codes as 2 different processes, 40 TCP connections will be created as follows. Since processes cannot access each other’s memory areas, a new TCP connection is created for each of them, even if we are requesting the same IP:PORT. Therefore, it is not desirable to create too many new processes when using these services.

If there is a target that we constantly request, “Connection: keep-alive” is suitable, but for targets that we access several times a day, there is no benefit in keeping the relevant connections open, it is best to close them. For example, the following scenarios may occur.

  • An HTTP API from which we want to get currency information.
  • The e-mail service we want to access when we want to send an e-mail.
  • TCP connections open when we want to send a log somewhere when a critical error occurs.

In these cases, we can terminate the connection when the request-response cycle is completed by adding a header as follows.

curl_setopt($ch[$i], CURLOPT_HTTPHEADER, ["Connection: close"]);

As seen in the screenshot, ESTABLISHED TCP connections start to close one by one after receiving the response from the remote machine and are completely eliminated. As a result, we do not have a connection that is ready and that we can use again. Because we won’t be making a request there again anytime soon, there’s no need to keep a connection waiting and waste resources.

Timeout Behavior of HTTP Requests

CURLOPT_CONNECTTIMEOUT: Determines the time it takes to establish a successful connection with a server. So, when curl is trying to connect to a server, it specifies the maximum time the connection should be established. If a connection to the server cannot be established within the specified time, the curl operation times out and returns an error.

CURLOPT_TIMEOUT: Specifies the maximum time allowed for a transaction to complete. That is, it determines the total time from initiation to completion of a request. If the operation is not completed within the specified time, the curl operation times out and returns an error.

<?php
try {
$url = 'http://server_web_container/bet';

$data = [
'action' => 'bet',
'amount' => 0.10
];

$ch = curl_init($url);

curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 0.5);
curl_setopt($ch, CURLOPT_TIMEOUT, 1);

$response = curl_exec($ch);

if ($response === false) {
throw new Exception(curl_error($ch));
}

curl_close($ch);

echo "Request handled successfully" . PHP_EOL;
} catch (\Throwable $e) {
echo "[ERROR]: " . $e->getMessage() . PHP_EOL;
}

In the code block above, we said that if we cannot establish any connection within 0.5 seconds, we do not proceed directly with the process because there is no need to wait until after that time. If we establish a connection and it does not respond within 1 second, we say that it is of no use to us and close the connection. Because in scenarios where the answers after that period are not important to us, there is no need to keep this PHP process waiting for more than 1 second. Instead of waiting for the other party, he can now try to establish new connections for new transactions. In the scenario here, we get an error like the one below because the server-side returns a response in more than 1 second.

If you have intensive PHP processes, CURLOPT_CONNECTTIMEOUT and CURLOPT_TIMEOUT values should be used. Because they will be very efficient for the next operations, there is no need to wait in vain for connections that have not yet responded in the expected time, they will no longer consume resources and make room for the next operations in our machine and application.

Laravel + Octane + Openswoole

When Laravel Octane and Swoole come together, they create a powerful combination to make Laravel applications faster and more scalable. Swoole acts as a web server running directly within your PHP application rather than web servers like Nginx or Apache, allowing it to handle requests more efficiently rather than launching a separate process for each HTTP request. However, when we create a TCP connection with curl_init, our service files do this for each new HTTP request. For this reason, it is useful to make a few configurations when using this trio to get rid of the creation cost.
First, let’s create a service file from which we will make HTTP requests, as follows. It receives a CurlHandle object from outside and fulfills the relevant requests thanks to operateRequest.

<?php

namespace App\Services;

use CurlHandle;

class HttpService
{
private CurlHandle $ch;

public function __construct(CurlHandle $ch) {
if (!isset($this->ch)) {
$this->ch = $ch;
}
}
public function operateRequest($url, $body): bool|string
{
curl_setopt($this->ch, CURLOPT_URL, $url);
curl_setopt($this->ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($this->ch, CURLOPT_POST, 1);
curl_setopt($this->ch, CURLOPT_POSTFIELDS, json_encode($body));

curl_setopt($this->ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'Connection: keep-alive'
]);

$response = curl_exec($this->ch);

if (curl_errno($this->ch)) {
return curl_error($this->ch);
}

//curl_close($this->ch);

return $response;
}

}

We have a Controller file like the one below that uses this place. In this application, when a request comes to this controller and method, we make another target HTTP request via HttpService.

<?php

namespace App\Http\Controllers;

use App\Services\HttpService;

class CasinoController extends Controller
{
public function getSampleData(HttpService $httpService): \Illuminate\Http\JsonResponse
{

$body = [
'title' => 'foo',
'body' => 'bar',
'userId' => 1,
];

$response = $httpService->operateRequest("localhost:8080", $body);

$data = json_decode($response, true);

if ($data === null) {
return response()->json(['error' => 'JSON converting error']);
}

return response()->json($data);
}
}

In the AppServiceProvider file owned by Laravel, we register our service as follows. In this way, instead of being created over and over again when this service is called more than once in the life cycle of 1 request, the first created version will be used. But our goal is not to create a life cycle within 1 request but to ensure that the Controller uses the same example when more than one request comes and does not create the same service over and over again.

<?php

namespace App\Providers;

use App\Services\HttpService;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
$this->app->singletonIf(HttpService::class, function () {
$ch = curl_init();
return new HttpService($ch);
});
}

/**
* Bootstrap any application services.
*/
public function boot(): void
{

}
}

Let’s first take a look at what happens when we run it this way. What we expect is; to open a new TCP connection for every HTTP request we make to this controller side. When I make 4 HTTP requests and check the TCP connections opened from this application, I see a total of 4 different TCP connections. However, we always make requests to the same target within the application.

To avoid this situation, we need to provide the service file I created for the configuration on the Octane side. In this way, the PHP workers launched by Octane and Swoole will from now on install those services once and will always use the same example wherever needed. We also add HttpService to the warm value in the array we returned in the config/octane.php file.

'warm' => [
...Octane::defaultServicesToWarm(),
\App\Services\HttpService::class,
],

This time I made 10 HTTP requests and created only 1 TCP connection for the same target, as shown below. Because HttpService is no longer started for each request, an instance is created once when Octane+Swoole is running, and that instance is used when needed. In this way, we avoid the cost of creating a TCP connection and can make requests over the same connection to the places we constantly make requests to.

If we do not make a request again for a certain period of time, the status of the connection to the same location changes to CLOSE_WAIT. That’s why it is important that we make this configuration for the goals we constantly desire.

Symfony + Swoole

On the Symfony side, we also have an HttpService file as follows. We added the service file config/services.yaml. Thus, we include the service we created in Symfony dependency management. Here again, a request is made to the relevant target according to the values received by the Controller. In the Symfony environment run by Swoole, TCP connections are constantly being created and we will change a few lines to get rid of this, but first let’s run this structure and see what happens.

parameters:

services:
_defaults:
autowire: true # Automatically injects dependencies in your services.
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.

App\:
resource: '../src/'
exclude:
- '../src/DependencyInjection/'
- '../src/Entity/'
- '../src/Kernel.php'

App\Service\HttpService: ~
<?php

namespace App\Service;

class HttpService
{
private \CurlHandle $ch;
public function __construct()
{
}

public function operateRequest($url, $body): bool|string
{
$this->ch = curl_init();
curl_setopt($this->ch, CURLOPT_URL, $url);
curl_setopt($this->ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($this->ch, CURLOPT_POST, 1);
curl_setopt($this->ch, CURLOPT_POSTFIELDS, json_encode($body));

curl_setopt($this->ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'Connection: keep-alive'
]);

$response = curl_exec($this->ch);

if (curl_errno($this->ch)) {
return curl_error($this->ch);
}

//curl_close($this->ch);
return $response;
}

}

Our controller file is as follows.

<?php

namespace App\Controller;

use App\Service\HttpService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Annotation\Route;

class DebitController extends AbstractController
{
#[Route('/debit', name: 'app_debit')]
public function debit(HttpService $httpService): JsonResponse
{
$body = [
'title' => 'foo',
'body' => 'bar',
'userId' => 1,
];

$response = $httpService->operateRequest("localhost:8080", $body);

$data = json_decode($response, true);

if ($data === null) {
return $this->json(['error' => 'JSON converting error']);
}

return $this->json($data);
}
}

As we make requests here, we can observe that TCP connections are created over and over again, even though they are the same target.

To avoid this situation, we define the curl value in __construct in the service file as follows.

<?php

namespace App\Service;

class HttpService
{
private \CurlHandle $ch;
public function __construct()
{
if (!isset($this->ch)) {
$this->ch = curl_init();
}
}

public function operateRequest($url, $body): bool|string
{

curl_setopt($this->ch, CURLOPT_URL, $url);
curl_setopt($this->ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($this->ch, CURLOPT_POST, 1);
curl_setopt($this->ch, CURLOPT_POSTFIELDS, json_encode($body));

curl_setopt($this->ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'Connection: keep-alive'
]);

$response = curl_exec($this->ch);

if (curl_errno($this->ch)) {
return curl_error($this->ch);
}

//curl_close($this->ch);
return $response;
}

}

Now I keep making requests to the Controller side and avoid having to constantly recreate TCP connections to the same target. Because while Swoole is starting up, our values in the service are defined once and stored in memory as long as the Swoole running life cycle continues. Of course, we should avoid unnecessary definitions and not fill the memory space unnecessarily, but if we have a service from which we will constantly make an HTTP request, keeping it ready will allow us to get rid of the cost of recreating TCP connections.

To Sum Up

It is important to open the correct TCP connections in PHP applications because it is the primary communication protocol of many internet-based applications and provides a reliable method of transmitting data. Unnecessary or excessive opening of TCP connections can cause a number of negative consequences. Here are the consequences of such situations:

Resource Exhaustion: Each TCP connection requires memory and processor resources. Opening unnecessary connections may drain the server’s resources. This may cause the server to slow down and affect other services or users.

Server Load: Unnecessary connections can cause server overload. Excessive use of server resources may cause the server to crash or become unresponsive.

Performance Issues: Unnecessary connections can increase network traffic and negatively impact server performance. This may cause users to access services slowly.

Connection Limits: Servers generally have limits on how many TCP connections they can open at the same time. Unnecessary connections may exceed these limits and deny access to other users.

Security Vulnerabilities: Unnecessary connections can lead to security vulnerabilities. In particular, links that can be used for phishing or security attacks can compromise the server.

Data Loss: Unnecessary connections can cause data loss or conflicts in the network. This may affect the reliability of the communication.

Poor Quality of Service: Unnecessary connections can reduce the overall quality of service of the network. This can cause problems, especially in real-time applications (for example, streaming video or voice communications).

As a result, opening unnecessary TCP connections can negatively impact both server and network performance. Therefore, it is important to manage links carefully and avoid unnecessary links. It is important to block or properly close unnecessary connections to effectively use network and server resources and reduce security risks.

--

--

Mert Simsek
Beyn Technology

I’m a software developer who wants to learn more. First of all, I’m interested in building, testing, and deploying automatically and autonomously.