Object Pool Pattern in Magento 2: Reuse Objects Instead of Creating New Ones | Mironsoft
AI generated
POOL
♻️
Deep Dive · Magento 2 Design Patterns

Object Pool Pattern:
Reuse Objects Instead of Creating New Ones

How Magento pools DB connections, HTTP clients and resource-intensive objects internally, and how you can use the pattern for your own expensive initializations.

12 min read
Magento 2.4.8 · PHP 8.4
GoF Creational Pattern

The Object Pool Pattern is one of the often overlooked Creational Patterns from the GoF catalog. The idea is simple: instead of creating and destroying an expensive object every time, you build up a stock (pool) of such objects and borrow them on demand, returning them once you are done. In languages like Java or C# this is indispensable for thread pools and connection pools. In a per-request PHP architecture the context changes, but the pattern stays highly relevant, as Magento itself proves.

1. What is the Object Pool Pattern?

The GoF Object Pool Pattern manages a set of pre-initialized objects that are expensive to create. The client asks the pool for an object, uses it, and returns it, rather than destroying it.


+------------------+         acquire()        +------------------+
|   Object Pool    | -----------------------> |   Pooled Object  |
|                  | <----------------------- |   (in use)       |
|  [obj1] [obj2]   |         release()        +------------------+
|  [obj3] [obj4]   |
+------------------+
         |
         | creates new if empty
         v
+------------------+
| Object Factory   |
+------------------+
    

The three core operations:

  • acquire() Fetch a free object from the pool (or create a new one if the pool is empty)
  • release() Return the object to the pool (reset it to its initial state)
  • create() Create a new instance when the pool is exhausted (optionally capped by a max size)

2. When does pooling make sense?

Not every object benefits from pooling. Some rules of thumb:

Scenario Creation cost Pool worthwhile?
Database connection TCP handshake, auth: ~50 to 200ms ✓ Yes
HTTP client (cURL) SSL handshake: ~30 to 100ms ✓ Yes (keep-alive)
PDF generator (Puppeteer/wkhtmltopdf) Browser start: ~500ms to 2s ✓ Yes
Elasticsearch client Connection + auth: ~20 to 80ms ✓ Yes
Simple DataObject <1ms ✗ No (overhead outweighs benefit)
ViewModel with DI <1ms (shared) ✗ No (Singleton is enough)

3. PHP 8.4 implementation

A generic pool implementation using PHP 8.4 constructor property promotion and typed properties:


<?php

declare(strict_types=1);

namespace Mironsoft\Core\Pool;

use SplQueue;
use Throwable;

/**
 * Generic object pool for expensive-to-create resources.
 *
 * @template T of object
 */
final class ObjectPool
{
    /** @var SplQueue<T> */
    private readonly SplQueue $available;

    private int $currentSize = 0;

    /**
     * @param callable(): T $factory   Creates a new instance when pool is empty
     * @param callable(T): void $reset Resets object state before returning to pool
     * @param int $maxSize             Maximum pool size (0 = unlimited)
     */
    public function __construct(
        private readonly \Closure $factory,
        private readonly \Closure $reset,
        private readonly int $maxSize = 10,
    ) {
        $this->available = new SplQueue();
    }

    /**
     * Acquires an object from the pool, creating one if necessary.
     *
     * @return T
     */
    public function acquire(): object
    {
        if (!$this->available->isEmpty()) {
            return $this->available->dequeue();
        }

        $this->currentSize++;
        return ($this->factory)();
    }

    /**
     * Returns an object to the pool after use.
     *
     * @param T $object
     */
    public function release(object $object): void
    {
        if ($this->maxSize > 0 && $this->available->count() >= $this->maxSize) {
            // Pool is full, let the object be garbage collected
            $this->currentSize--;
            return;
        }

        ($this->reset)($object);
        $this->available->enqueue($object);
    }

    /**
     * Returns current pool statistics.
     *
     * @return array{available: int, total: int}
     */
    public function stats(): array
    {
        return [
            'available' => $this->available->count(),
            'total'     => $this->currentSize,
        ];
    }
}
    

Usage with a concrete resource type:


<?php

// Build a pool of cURL handles (example of pool usage)
$curlPool = new ObjectPool(
    factory: static function (): \CurlHandle {
        $handle = curl_init();
        curl_setopt($handle, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($handle, CURLOPT_TIMEOUT, 10);
        return $handle;
    },
    reset: static function (\CurlHandle $handle): void {
        curl_reset($handle);
        curl_setopt($handle, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($handle, CURLOPT_TIMEOUT, 10);
    },
    maxSize: 5,
);

// Use the pool
$handle = $curlPool->acquire();
curl_setopt($handle, CURLOPT_URL, 'https://api.example.com/data');
$response = curl_exec($handle);
$curlPool->release($handle); // Returns to pool instead of destroying
    

4. Magento: the DB connection pool (ResourceConnection)

Magento implements a connection pool concept internally through Magento\Framework\App\ResourceConnection. Instead of opening a new TCP connection for every database access, connections are cached and reused.


<?php

declare(strict_types=1);

namespace Mironsoft\Example\Model;

use Magento\Framework\App\ResourceConnection;
use Magento\Framework\DB\Adapter\AdapterInterface;

/**
 * Demonstrates how Magento pools database connections internally.
 */
final class ProductRepository
{
    public function __construct(
        private readonly ResourceConnection $resourceConnection,
    ) {}

    /**
     * Magento's getConnection() returns the SAME AdapterInterface
     * instance every time within one request, this IS the pool.
     */
    public function getActiveProductCount(): int
    {
        // First call: opens TCP connection to MySQL, creates Adapter
        $connection = $this->resourceConnection->getConnection();

        // Second call anywhere in the request: returns SAME instance
        // No new TCP handshake, no re-authentication
        $connection2 = $this->resourceConnection->getConnection();

        // $connection === $connection2 -> true (same object)

        $table = $this->resourceConnection->getTableName('catalog_product_entity');

        return (int) $connection->fetchOne(
            $connection->select()
                ->from($table, ['COUNT(*)'])
                ->where('status = ?', 1)
        );
    }

    /**
     * Named connections: separate pool entries for read/write splitting.
     */
    public function getWithReadReplica(): AdapterInterface
    {
        // Magento supports named connections (read replica)
        // Each name maps to a separate pooled connection
        return $this->resourceConnection->getConnection('read');
    }
}
    

The internal pooling logic in ResourceConnection::getConnection():


<?php

// Simplified from vendor/magento/framework/App/ResourceConnection.php
// The actual implementation uses _connections[] array as the pool

class ResourceConnection
{
    /** @var AdapterInterface[] Pool indexed by connection name */
    private array $connections = [];

    public function getConnection(string $resourceName = self::DEFAULT_CONNECTION): AdapterInterface
    {
        $connectionName = $this->getConnectionName($resourceName);

        // Pool lookup: return existing connection if available
        if (isset($this->connections[$connectionName])) {
            return $this->connections[$connectionName]; // <- Pool hit
        }

        // Pool miss: create new connection and store in pool
        $this->connections[$connectionName] = $this->connectionFactory->create(
            $this->deploymentConfig->get("db/connection/{$connectionName}")
        );

        return $this->connections[$connectionName];
    }
}
    

This is the classic Object Pool Pattern, just without an explicit release(), since PHP frees the memory at the end of the request anyway.

5. HTTP client pool in Magento

When talking to external APIs (payment gateways, ERP systems), a cURL pool pays off because it avoids repeated SSL handshakes:


<?php

declare(strict_types=1);

namespace Mironsoft\Integration\Http;

/**
 * Pools cURL handles to reuse SSL sessions within a single request.
 */
final class CurlHandlePool
{
    /** @var \CurlHandle[] */
    private array $available = [];

    /** @var \CurlHandle[] */
    private array $inUse = [];

    private const MAX_POOL_SIZE = 8;

    /**
     * Acquires a cURL handle, reusing an existing one if possible.
     */
    public function acquire(): \CurlHandle
    {
        if (!empty($this->available)) {
            $handle = array_pop($this->available);
            $this->inUse[spl_object_id($handle)] = $handle;
            return $handle;
        }

        $handle = $this->createHandle();
        $this->inUse[spl_object_id($handle)] = $handle;
        return $handle;
    }

    /**
     * Returns a handle to the pool and resets its state.
     */
    public function release(\CurlHandle $handle): void
    {
        $id = spl_object_id($handle);
        unset($this->inUse[$id]);

        if (count($this->available) < self::MAX_POOL_SIZE) {
            curl_reset($handle);
            curl_setopt_array($handle, $this->defaultOptions());
            $this->available[] = $handle;
        } else {
            curl_close($handle);
        }
    }

    private function createHandle(): \CurlHandle
    {
        $handle = curl_init();
        curl_setopt_array($handle, $this->defaultOptions());
        return $handle;
    }

    /** @return array<int, mixed> */
    private function defaultOptions(): array
    {
        return [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT        => 30,
            CURLOPT_CONNECTTIMEOUT => 10,
            CURLOPT_HTTP_VERSION   => CURL_HTTP_VERSION_2_0,
            CURLOPT_TCP_KEEPALIVE  => 1,
        ];
    }
}
    

Registering it as a shared object in di.xml (the pool must be a singleton):


<!-- app/code/Mironsoft/Integration/etc/di.xml -->
<config>
    <type name="Mironsoft\Integration\Http\CurlHandlePool">
        <!-- shared="true" is the default, stated explicitly here for clarity -->
        <!-- The pool MUST be a singleton so all callers share the same pool -->
        <arguments>
            <!-- Max pool size configurable via di.xml -->
        </arguments>
    </type>
</config>
    

6. Building your own pool: a PDF generator example

A realistic Magento scenario: PDF generation through an external process (for example headless Chrome via Puppeteer). Starting the process costs roughly 1 to 2 seconds, with pooling that only happens once per request lifecycle.


<?php

declare(strict_types=1);

namespace Mironsoft\Pdf\Pool;

use Psr\Log\LoggerInterface;

/**
 * Manages a pool of external PDF renderer processes.
 */
final class PdfRendererPool
{
    /** @var PdfRenderer[] */
    private array $available = [];

    private int $created = 0;

    public function __construct(
        private readonly PdfRendererFactory $rendererFactory,
        private readonly LoggerInterface $logger,
        private readonly int $maxPoolSize = 3,
        private readonly int $maxIdleTime = 300, // seconds
    ) {}

    /**
     * Acquires a PDF renderer, starting a new process if necessary.
     */
    public function acquire(): PdfRenderer
    {
        // Remove stale renderers first
        $this->evictStale();

        if (!empty($this->available)) {
            return array_pop($this->available);
        }

        if ($this->created >= $this->maxPoolSize) {
            // Block and wait, or throw if strict mode
            throw new \RuntimeException(
                "PDF renderer pool exhausted (max: {$this->maxPoolSize})"
            );
        }

        $this->logger->info('Starting new PDF renderer process');
        $renderer = $this->rendererFactory->create();
        $this->created++;
        return $renderer;
    }

    /**
     * Returns a renderer to the pool after use.
     */
    public function release(PdfRenderer $renderer): void
    {
        if (!$renderer->isHealthy()) {
            $renderer->terminate();
            $this->created--;
            return;
        }

        $renderer->markLastUsed(time());
        $this->available[] = $renderer;
    }

    /**
     * Removes renderers that have been idle too long.
     */
    private function evictStale(): void
    {
        $now = time();
        $this->available = array_filter(
            $this->available,
            function (PdfRenderer $r) use ($now): bool {
                if ($now - $r->lastUsed() > $this->maxIdleTime) {
                    $r->terminate();
                    $this->created--;
                    return false;
                }
                return true;
            }
        );
    }
}
    

The service that uses the pool:


<?php

declare(strict_types=1);

namespace Mironsoft\Pdf\Service;

use Mironsoft\Pdf\Pool\PdfRendererPool;
use Magento\Sales\Api\Data\OrderInterface;

/**
 * Generates order PDF documents using a pooled renderer.
 */
final class OrderPdfService
{
    public function __construct(
        private readonly PdfRendererPool $rendererPool,
    ) {}

    /**
     * Generates a PDF for the given order.
     */
    public function generateOrderPdf(OrderInterface $order): string
    {
        $renderer = $this->rendererPool->acquire();
        try {
            $html = $this->buildOrderHtml($order);
            return $renderer->render($html, ['format' => 'A4']);
        } finally {
            // Always release back to pool, even on exception
            $this->rendererPool->release($renderer);
        }
    }

    private function buildOrderHtml(OrderInterface $order): string
    {
        return sprintf(
            '<html><body><h1>Order #%s</h1><p>Total: %s</p></body></html>',
            $order->getIncrementId(),
            $order->getGrandTotal()
        );
    }
}
    

The finally block is critical: the renderer must always go back to the pool, even on exceptions, otherwise the pool leaks.

7. PHP request scope: why classic pooling is limited

By default PHP is a share-nothing architecture: every request starts a new PHP process (or FPM worker), which is fully cleaned up at the end. This means:

PHP quirks with Object Pool

  • No cross-request pooling: A pool only lives within a single request. For persistent pooling you need an external proxy (PgBouncer, ProxySQL, HAProxy).
  • PHP-FPM worker as an implicit pool: FPM itself pools PHP worker processes. Persistent connections (pconnect) make use of this worker pool.
  • Swoole/RoadRunner: with coroutine frameworks the process persists across multiple requests, which makes cross-request pooling possible and worthwhile.
  • Still worthwhile: within a single long-running request (batch import, queue consumer) with many DB queries or API calls, intra-request pooling pays off significantly.

For Magento queue consumers running as long-running processes, Object Pool is especially relevant:


<?php

declare(strict_types=1);

namespace Mironsoft\Queue\Consumer;

use Magento\Framework\MessageQueue\ConsumerInterface;
use Magento\Framework\MessageQueue\EnvelopeInterface;
use Mironsoft\Integration\Http\CurlHandlePool;

/**
 * Long-running queue consumer that benefits from cURL handle pooling.
 * This process may handle thousands of messages before being recycled.
 */
final class WebhookDispatcher implements ConsumerInterface
{
    public function __construct(
        private readonly CurlHandlePool $curlPool,
    ) {}

    public function process(EnvelopeInterface $envelope): void
    {
        $handle = $this->curlPool->acquire();
        try {
            $message = json_decode($envelope->getBody(), true, 512, JSON_THROW_ON_ERROR);
            curl_setopt($handle, CURLOPT_URL, $message['webhook_url']);
            curl_setopt($handle, CURLOPT_POSTFIELDS, json_encode($message['payload']));
            curl_exec($handle);
        } finally {
            $this->curlPool->release($handle);
        }
    }
}
    

8. Pool vs. Singleton vs. Factory

Pattern Instances State Ideal for
Singleton 1 (always) Shared (dangerous) Stateless services, configuration
Factory N (fresh every time) Fresh, isolated Non-injectable, cheap creation
Object Pool M (bounded, reused) Reset between uses Expensive creation, frequent use
Prototype N (via clone) Copied from the original Complex initial states, templates

The rule of thumb: if creating an object measurably costs time (>10ms) and you need it often, a pool makes sense. If the state cannot be reset, use Factory. If you only need one instance and the state is stateless, use Singleton (shared di.xml).

9. Testing object pools

There are three critical scenarios to cover when testing pools:


<?php

declare(strict_types=1);

namespace Mironsoft\Core\Test\Unit\Pool;

use Mironsoft\Core\Pool\ObjectPool;
use PHPUnit\Framework\TestCase;

final class ObjectPoolTest extends TestCase
{
    private int $createCount = 0;

    private function buildPool(int $maxSize = 5): ObjectPool
    {
        return new ObjectPool(
            factory: function (): \stdClass {
                $this->createCount++;
                $obj = new \stdClass();
                $obj->id = $this->createCount;
                $obj->value = null;
                return $obj;
            },
            reset: static function (\stdClass $obj): void {
                $obj->value = null; // Reset state before returning to pool
            },
            maxSize: $maxSize,
        );
    }

    public function testAcquireCreatesNewObjectWhenPoolEmpty(): void
    {
        $pool = $this->buildPool();
        $obj = $pool->acquire();

        $this->assertSame(1, $this->createCount);
        $this->assertInstanceOf(\stdClass::class, $obj);
    }

    public function testReleaseAndReacquireReusesObject(): void
    {
        $pool = $this->buildPool();
        $obj1 = $pool->acquire();
        $pool->release($obj1);

        $obj2 = $pool->acquire();

        // Only ONE object was ever created
        $this->assertSame(1, $this->createCount);
        // It's the SAME instance
        $this->assertSame($obj1, $obj2);
    }

    public function testResetIsCalledOnRelease(): void
    {
        $pool = $this->buildPool();
        $obj = $pool->acquire();
        $obj->value = 'dirty-state';
        $pool->release($obj);

        $reacquired = $pool->acquire();
        // State must be reset
        $this->assertNull($reacquired->value);
    }

    public function testPoolDoesNotExceedMaxSize(): void
    {
        $pool = $this->buildPool(maxSize: 2);

        $a = $pool->acquire();
        $b = $pool->acquire();
        $c = $pool->acquire(); // Creates 3rd

        // Release all three
        $pool->release($a);
        $pool->release($b);
        $pool->release($c); // Pool is full, $c gets discarded

        $stats = $pool->stats();
        $this->assertLessThanOrEqual(2, $stats['available']);
    }

    public function testConcurrentAcquireCreatesSeparateObjects(): void
    {
        $pool = $this->buildPool();
        $obj1 = $pool->acquire();
        $obj2 = $pool->acquire(); // Pool empty, new object

        $this->assertSame(2, $this->createCount);
        $this->assertNotSame($obj1, $obj2);
    }
}
    

10. Conclusion: when to use Object Pool in Magento

The Object Pool Pattern is already used in Magento in the right places (DB connections, HTTP clients), and you should deliberately reach for it for your own requirements:

✓ Use a pool when...

  • Creation costs more than 10ms (connection, process)
  • The object is needed multiple times within a request
  • The state can be reset after use
  • Long-running queue consumer (Magento MQ)
  • External API connections with SSL overhead

✗ Avoid a pool when...

  • Creation is cheap (<1ms)
  • Objects are mutable and cannot be reset
  • Only one instance is needed (then: Singleton/Shared)
  • Standard HTTP request with 1 to 2 DB queries
  • State differs critically between uses

Magento itself shows the right way: ResourceConnection pools DB connections transparently, without the developer having to think about it. That is good Object Pool design: easy to use, hard to misuse.

Summary

Pattern
Object Pool manages a bounded number of expensive objects for reuse instead of recreation
Magento Core
ResourceConnection pools DB adapter instances; every getConnection() call returns the same instance
PHP scope
Pool lives per request (FPM); cross-request pooling is only possible with Swoole/RoadRunner or an external proxy
Mandatory
Always use a finally block for release(): the pool must never run dry because of unhandled exceptions

Object Pool in practice

Analyzing connection pools, optimizing HTTP clients or speeding up queue consumers.

????
Pool analysis
Analyzing ResourceConnection usage and pool hit rates
cURL optimization
Implementing an HTTP client pool for external API integrations
????
Queue consumers
Long-running Magento MQ consumers with resource pooling

Frequently asked questions about the Object Pool Pattern

What is the Object Pool Pattern? +
The Object Pool Pattern is a Creational design pattern that manages a stock of pre-initialized objects. Instead of creating and destroying an expensive object every time, objects are borrowed (acquire) and returned after use (release), being reset to their initial state along the way.
Does Magento use the Object Pool Pattern internally? +
Yes. Magento's ResourceConnection implements a connection pool concept internally: getConnection() returns the same adapter instance on every call instead of opening a new TCP connection. This is the classic Object Pool Pattern, just without an explicit release().
When is an Object Pool worthwhile in PHP/Magento? +
When creating the object measurably costs time (more than 10ms), for example database connections (TCP handshake), HTTPS clients (SSL handshake), external processes (PDF generator). With cheap objects, the pool overhead outweighs the benefit.
Why is pooling only of limited use in PHP? +
PHP is by default a share-nothing architecture: every request starts with fresh memory. An Object Pool only lives within a single request. Cross-request pooling is only possible with Swoole, RoadRunner or external proxies.
What is the difference between Object Pool and Singleton? +
A Singleton allows exactly one instance that is always shared. An Object Pool allows M instances that are used one after another, with an explicit reset. The pool scales better under concurrent demand.
Why is the finally block important when releasing a pool object? +
Without finally, an exception can cause the borrowed object to never be returned. The pool gradually runs dry over time (a pool leak). try/finally ensures that release() always runs.
How exactly does Magento's DB connection pooling work? +
ResourceConnection::getConnection() checks an internal $_connections array. If a connection already exists for the requested name, it is returned. Only on the first call is a real TCP connection to MySQL opened.
Can I configure the pool through Magento DI? +
Yes. The pool must be registered as a singleton (shared=true, the default) so that all classes share the same pool. Configuration parameters such as maxPoolSize can be set through di.xml arguments.
What needs to be considered when resetting a pool object? +
The reset must return the object to the same state it had right after creation. For cURL handles: curl_reset(). For your own classes: reset all properties to their defaults. An incomplete reset leads to subtle bugs.
Is Object Pool suitable for Magento queue consumers? +
Yes, this is one of the best use cases. Queue consumers run as long-running processes and process thousands of messages. Without pooling, every message would trigger a new cURL SSL handshake. With pooling, the connection is reused.