Contents
- 1. What is the Object Pool Pattern?
- 2. When does pooling make sense?
- 3. PHP 8.4 implementation
- 4. Magento: the DB connection pool (ResourceConnection)
- 5. HTTP client pool in Magento
- 6. Building your own pool: a PDF generator example
- 7. PHP request scope: why classic pooling is limited
- 8. Pool vs. Singleton vs. Factory
- 9. Testing object pools
- 10. Conclusion: when to use Object Pool in Magento
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
Object Pool in practice
Analyzing connection pools, optimizing HTTP clients or speeding up queue consumers.