---
title: 'Memory and Connection Management'
source: 'https://academia.sh/en/courses/php-production/memory-and-connection-management'
course: 'Quality, Performance and Deployment in PHP'
language: en
updated: '2026-10-21T05:03:36+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Show that the request stops when memory_limit is exceeded and that the shutdown function sees this through error_get_last()'
    - "Show that a connection object's destructor runs at the end of the request, and distinguish the concepts of persistent connection and connection pool"
    - 'Interpret why, with a circular reference, the destructor does not run right after unset() but does run after gc_collect_cycles()'
---

# Memory and Connection Management

Measuring how a request stops when the memory limit is exceeded, when a connection object's destructor runs, and how a circular reference delays cleanup.

A PHP request runs with an allotted share of memory. The share is set by the `memory_limit` directive; if it is exceeded, the request stops with an unrecoverable error. A resource as important as memory is the connection: every connection opened to a database or cache server holds a place on the other side until it is closed somewhere. This lesson measures how both are tied to the life of the request. The concept of the request lifetime was established in the previous course; here it is only the subject of measurement.

The measuring question is again the shared question: does this behavior reach production? `side.php` is below verbatim, and in this lesson too it is only written to disk.

```php
<?php
// side.php — does this behavior reach production?
declare(strict_types=1);

// The two configuration files PHP ships (php.ini-development, php.ini-production) differ
// in seven directives; all seven are copied here verbatim. The last two production lines
// are comments in the shipped file: code cache on for the command line too, file checks off.
const PROFILES = [
    'development' => [
        'display_errors' => '1',
        'display_startup_errors' => '1',
        'error_reporting' => '32767',
        'mysqlnd.collect_memory_statistics' => '1',
        'zend.assertions' => '1',
        'zend.exception_ignore_args' => '0',
        'zend.exception_string_param_max_len' => '15',
    ],
    'production' => [
        'display_errors' => '0',
        'display_startup_errors' => '0',
        'error_reporting' => '24575',
        'mysqlnd.collect_memory_statistics' => '0',
        'zend.assertions' => '-1',
        'zend.exception_ignore_args' => '1',
        'zend.exception_string_param_max_len' => '0',
        'opcache.enable_cli' => '1',
        'opcache.validate_timestamps' => '0',
    ],
];

/** Runs the script in a separate process with no configuration file, only the profile's directives. */
function run(string $script, array $directives): bool {
    $command = ['php', '-n'];
    foreach ($directives as $name => $value) {
        array_push($command, '-d', "$name=$value");
    }
    $command[] = $script;
    $process = proc_open($command, [1 => ['pipe', 'w'], 2 => ['file', '/dev/null', 'w']], $pipes);
    $output = stream_get_contents($pipes[1]);
    fclose($pipes[1]);
    proc_close($process);
    $lines = array_values(array_filter(array_map('trim', explode("\n", $output))));
    return end($lines) === 'yes';
}

/** Derives the side from whether the behavior was observed in the two runs. */
function side(bool $inDevelopment, bool $inProduction): string {
    return match (true) {
        $inDevelopment && $inProduction => 'both sides',
        $inDevelopment => 'development only',
        $inProduction => 'production only',
        default => 'not in the run',
    };
}

function sideReport(string $name, string $script): string {
    $inDevelopment = run($script, PROFILES['development']);
    $inProduction = run($script, PROFILES['production']);
    $finalSide = side($inDevelopment, $inProduction);
    echo "$name → $finalSide\n    development ", $inDevelopment ? 'yes' : 'no',
        ' · production ', $inProduction ? 'yes' : 'no', "\n";
    return $finalSide;
}
```

To understand the behavior of the memory limit, we recall what the limit is for. PHP keeps a single request within its own share; the share is a safeguard that prevents a faulty loop or an unexpectedly large input in one request from consuming the whole machine. When the limit is exceeded, PHP abandons the work halfway, because there is no safe way to continue: no memory is left to allocate. The request stops, but before stopping, PHP calls the shutdown functions. To observe this behavior, the probe script deliberately accumulates one-megabyte strings in an endless loop. Because the strings are held in an array, none of them is freed and the limit is inevitably exceeded.

The first measurement contains two probes. The first pulls the memory limit down to eight megabytes with `ini_set('memory_limit', '8M')` and allocates memory until the limit is exceeded. When the request stops, a shutdown function comes into play and looks at the type of the last error with `error_get_last()`. The second builds a connection object and checks whether its destructor runs after the script ends. The output has no error message text or byte count; the probes print only `yes` or `no`.

```php
<?php
// memory.php — the request stops when the limit is exceeded, the destructor runs at the end of the request
declare(strict_types=1);
require __DIR__ . '/side.php';

file_put_contents('limit-exceeded.php', <<<'PHP'
<?php
// limit-exceeded.php — when memory_limit is exceeded, the shutdown function sees the error
ini_set('memory_limit', '8M');
register_shutdown_function(function (): void {
    $last = error_get_last();
    echo $last !== null && $last['type'] === E_ERROR ? "yes\n" : "no\n";
});
$chunks = [];
while (true) {
    $chunks[] = str_repeat('a', 1024 * 1024);
}
PHP);

file_put_contents('destructor.php', <<<'PHP'
<?php
// destructor.php — the connection object's destructor runs after the script
final class Connection {
    public function __destruct() {
        echo "yes\n";
    }
}
$connection = new Connection();
echo "script finished\n";
PHP);

sideReport('a request that exceeds the memory limit stops', 'limit-exceeded.php');
sideReport('the destructor runs when the request ends', 'destructor.php');
```

```
a request that exceeds the memory limit stops → both sides
    development yes · production yes
the destructor runs when the request ends → both sides
    development yes · production yes
```

When the limit was exceeded, the request stopped and the shutdown function saw that the last error was `E_ERROR`; the destructor also ran after the script ended. Both are the same in the two profiles: the side is **both sides**. The error message that appears on screen in development is hidden in production, but the stopping of the request and the calling of the shutdown function do not depend on the settings. So if you want to catch the death of a request and write it to the log, the shutdown function is the path that works in both profiles.

Connections are tied to this lifetime in two ways. An ordinary connection is born with the request and closes when its destructor runs, that is, when the request ends. A persistent connection does not close when the request ends; the process keeps it for the next request. A connection pool goes one step further: it keeps connections in a layer independent of the processes and shares them among requests. Neither was set up in this lesson; they are only mentioned as concepts, and what is measured is the destructor of the ordinary connection.

## Shutdown Function and Destructor Order

In the previous section we saw two separate mechanisms: the shutdown function that comes into play when the limit is exceeded, and the destructor that runs at the end of an object's life. In a real application the two exist together, and which runs first matters. If you write one last record to the database in the shutdown function, the connection object must not have been destroyed up to that moment. This section measures the order. The connection object's destructor checks whether the shutdown function has run and prints `yes` or `no` accordingly. In the first probe the object lives until the end of the script; in the second, after registering the shutdown function, the script releases the object itself with `unset()`.

```php
<?php
// order.php — in what order do the shutdown function and the destructor run?
declare(strict_types=1);
require __DIR__ . '/side.php';

$classBody = <<<'PHP'
<?php
final class Connection {
    public static bool $shutdownRan = false;
    public bool $shutdownFirst = true;
    public function __destruct() {
        echo self::$shutdownRan === $this->shutdownFirst ? "yes\n" : "no\n";
    }
}
register_shutdown_function(function (): void {
    Connection::$shutdownRan = true;
});
PHP;

file_put_contents('late.php', $classBody . <<<'PHP'

$connection = new Connection();
PHP);

file_put_contents('early.php', $classBody . <<<'PHP'

$connection = new Connection();
$connection->shutdownFirst = false;
unset($connection);
PHP);

sideReport('at the end of the script the shutdown function runs before the destructor', 'late.php');
sideReport('the destructor of an object released with unset() runs before the shutdown', 'early.php');
```

```
at the end of the script the shutdown function runs before the destructor → both sides
    development yes · production yes
the destructor of an object released with unset() runs before the shutdown → both sides
    development yes · production yes
```

We first explain the design of the two probes. In both, the shutdown function does a single job: it sets the `Connection::$shutdownRan` flag to `true`. The destructor looks at the current value of the flag and compares it with the object's expectation of "should the shutdown run first"; if they agree it prints `yes`, if not `no`. In this way the order information becomes the output of the destructor and no separate log file is needed. In the first probe the object lives until the end of the script and the expectation is "shutdown first". In the second, the script releases the object with `unset()`; since no other reference holds the object, the destructor runs immediately, and the expectation is "shutdown after", which is conveyed by setting the `shutdownFirst` field to `false`.

Both lines gave `yes` in both profiles. The meaning of the first line is this: if you let an object live until the end of the script, the shutdown functions run first and the objects are destroyed afterwards. This order means that in the shutdown function you can still use the connection object: you can safely do jobs such as writing the last record to the log, finishing an open transaction, or leaving a job in a queue. The meaning of the second line is the opposite: if you released the object by hand, the destructor runs before the shutdown and the shutdown function can no longer find that connection. Both behaviors are rules of the language and, since they do not depend on the profile setting, they are of the **both sides** kind.

The production counterpart of this is a simple rule. Carry a resource you will use in the shutdown function by binding it with `use` into the anonymous function you register, or as an object property; do not release a global variable with `unset()`. When the memory limit is exceeded the shutdown function still runs, but since memory has run out at that point, the work the function can do is small: something like only writing the error to the log. A large cleanup or opening a new connection often fails at this moment. This is why the shutdown function in our limit-exceeded measurement only read `error_get_last()`, a deliberate choice.


## The Bounding Measurement

That the destructor "runs at the end of the request" does not mean it runs the moment you release the object. If an object carries a reference that leads back to itself, that is, if two objects hold each other, `unset()` does not destroy either of them immediately. In this example two objects hold each other. The probes were written in the affirmative direction: does the destructor not run immediately after `unset()`, and does it run after `gc_collect_cycles()`?

```php
<?php
// cycle.php — when does the destructor run with a circular reference?
declare(strict_types=1);
require __DIR__ . '/side.php';

$body = <<<'PHP'
<?php
class Node {
    public static int $destroyed = 0;
    public ?Node $neighbor = null;
    public function __destruct() {
        self::$destroyed++;
    }
}
$a = new Node();
$b = new Node();
$a->neighbor = $b;
$b->neighbor = $a;
unset($a, $b);
$afterUnset = Node::$destroyed;
gc_collect_cycles();
$afterCollector = Node::$destroyed;
PHP;

file_put_contents('immediate.php', $body . "\necho \$afterUnset === 0 ? \"yes\\n\" : \"no\\n\";\n");
file_put_contents('collector.php', $body . "\necho \$afterCollector === 2 ? \"yes\\n\" : \"no\\n\";\n");

sideReport('after unset() the destructor does not run immediately', 'immediate.php');
sideReport('after gc_collect_cycles() the destructor runs', 'collector.php');
```

```
after unset() the destructor does not run immediately → both sides
    development yes · production yes
after gc_collect_cycles() the destructor runs → both sides
    development yes · production yes
```

Both probes gave `yes` in both profiles. After `unset()` the destructor did not run, because the two objects hold each other and the reference count did not fall to zero. The `gc_collect_cycles()` call found the cycle and destroyed both, and the destructor ran twice. The practical consequence is this: if an object whose destructor closes the connection is part of a circular structure, the connection closes not with `unset()` but either with a `close()` call you write explicitly or when the cycle collector runs. Entrusting the connection to the destructor means assuming there is no cycle in your object graph. (**PO3.** No directive concerning the cycle collector was changed; the probes measure the default behavior.)

The same lesson comes out on the memory side. A request that exceeds the limit stops by calling the shutdown function; the limit is the share of each request, and when the request ends that share is freed. In a long-lived process, such as a queue worker process, circular structures can accumulate because the process is not reset by finished requests, and `memory_limit` is the last safeguard that catches this accumulation.

We read the bounding measurement with the shared method of this course: we state a claim and make a measurement that could refute it. The claim was "the destructor runs the moment you release the object". The refuting measurement is to tie two objects to each other and call `unset()`. The destructor did not run; so the claim is not true under this condition. The corrected claim is narrower: the destructor runs when the last reference to the object drops. In a circular structure the last reference does not drop, because the objects keep each other alive. This is why, instead of loading the responsibility of closing the connection on the destructor, it is a sounder arrangement for the code that opens the connection to close it explicitly; the destructor remains as a backup safety net.

The limit of the measurement is also clear: the probes count objects, they do not build connections. Seeing the number of open connections on the other side of a real database server requires a separate tool and a separate environment; the measurement of this lesson proves whether the destructor runs, not what running does on the other side. The two are connected but are not the same measurement.

In this section we made the `gc_collect_cycles()` call by hand, because the measurement has to be repeatable. In a real application you generally do not make this call; the cycle collector runs when it reaches its own threshold. In long-lived worker processes, calling it by hand at the end of a large job can be a deliberate choice, but apply it not as a rule but as an exception verified by measurement.

We combine these two concepts. Consider a queue worker process: a long-lived process that builds a connection object on each job and releases it when the job is done. If the connection object and another object hold each other, for example a "session" object holding the connection and the connection holding the session, `unset()` closes no connection. Until the cycle collector runs, the connections stay open on the other side. The collector runs by itself at a certain accumulation threshold, but your code cannot know when it will run. If the connection limit of the other side is small, this uncertainty shows up one morning as a connection error. We did not set up a connection server in this lesson; but the circular reference probe measures the same behavior with an object counter.

Placing the four behaviors tested by the three measurements of the lesson side by side shows a common structure. The limit being exceeded, the destructor timing, the shutdown order and the circular reference are all runtime rules of the language and do not depend on the profile difference of the `php.ini` settings. This is a reassuring finding, because it lets you learn memory and connection behavior in development and expect it unchanged in production. The only thing to watch is the setting itself: the configuration file determines the `memory_limit` value, and the point at which the limit is exceeded depends on that value. In the two ready-made profiles the value is the same, 128M, but a deployment's own configuration file can change it. In this lesson we pulled the limit down to eight megabytes inside the script and made the two profiles arrive at the same point. In a real deployment the command line process and the web process may read different configuration files; in that case testing the queue worker process with the web setting gives false confidence for the worker process. Measure knowingly, with the value of the process you measure, and write the value in your deployment document; when the limit is exceeded one day, that is the first place to look.

## Summary

- When `memory_limit` is exceeded the request stops; the shutdown function sees through `error_get_last()` that the last error is `E_ERROR`.
- The side of this lesson is **both sides**: the limit-exceeded, destructor behavior and circular reference measurements all gave the same result in the development and production profiles.
- A connection object's destructor runs after the script ends; the persistent connection and the connection pool are concepts that extend this lifetime beyond requests.
- With a circular reference, `unset()` does not run the destructor immediately; `gc_collect_cycles()` does.
- Leaving the closing of the connection to the destructor means trusting that there is no cycle in the object graph.
- The destructor of an object that lives until the end of the script runs after the shutdown function, while the destructor of an object released with `unset()` runs before the shutdown; both orders are the same in the two profiles (side: both sides).

## Next Step

Memory and connections were managed inside a request. Every behavior measured in this lesson was a rule of the runtime; the behavior measured in the next lesson depends on the settings and on how the process is started. Who serves the requests is a separate question: the web server and the process manager distribute requests to worker processes. The next lesson starts the built-in web server with two workers and measures what is carried across requests and what is not.
