Table of Contents
- The PHP clone() mechanism in detail
- Shallow copy vs. deep copy: the crucial difference
- The Prototype Pattern in Magento 2: cloning quote items
- Why clone() instead of new: the performance perspective
- When clone() is dangerous: reference traps in Magento objects
- The Object Pool Pattern: concept and use cases
- Object pools in Magento 2: shared instances and connection pooling
- Implementing your own object pool with PHP 8.4
- Summary
- FAQ
The PHP clone() mechanism in detail
In PHP, the Prototype Pattern is built on a language mechanism baked right into the runtime: the clone keyword. When you call clone $object, PHP internally creates a new instance of the same class and copies all properties from the original. No constructor is invoked in the process, which is the key difference compared to new. Afterward, PHP calls the magic method __clone() on the freshly created clone, provided it is defined.
The Prototype Pattern is one of the classic creational patterns from the GoF (Gang of Four) book. It is well suited whenever fully initializing an object is expensive (for example because it requires database queries, network communication, or complex calculations) and when many similar objects that differ only in a few details are needed. Instead of repeating every step of initialization, you take an already fully configured object as a template and create a copy of it.
In PHP 8.4 the clone mechanism itself is unchanged, but it is complemented by new language features. Since PHP 8.3, readonly properties can be written inside __clone(), which previously triggered an error. This enables genuinely immutable value objects that can still be cloned and slightly modified. PHP 8.4 additionally introduces the clone with syntax, which allows properties to be overwritten directly during cloning without having to write a __clone() method.
In Magento 2.4.8, the PHP clone mechanism is deliberately used in several places, sometimes obviously, sometimes hidden deep inside the framework. To understand why, you first need to understand the difference between shallow copy and deep copy.
<?php
declare(strict_types=1);
// PHP 8.4: clone with, direct property override while cloning
class ProductPrice
{
public function __construct(
public readonly string $sku,
public readonly float $price,
public readonly string $currency = 'EUR'
) {}
}
$basePrice = new ProductPrice('SKU-001', 29.99, 'EUR');
// PHP 8.4: clone with overrides properties without __clone()
$priceUsd = clone $basePrice with { currency: 'USD', price: 32.50 };
// Classic approach: __clone() for more complex objects
class QuoteItemPrototype
{
public function __construct(
private string $sku,
private float $qty,
private ?AddressData $shippingAddress = null
) {}
// Deep clone: explicitly clone nested objects
public function __clone(): void
{
if ($this->shippingAddress !== null) {
$this->shippingAddress = clone $this->shippingAddress;
}
}
}
Shallow copy vs. deep copy: the crucial difference
PHP's clone produces a shallow copy by default. That means all properties with scalar types (int, float, string, bool, null) are copied in full and exist independently in the clone. Arrays are copied too, and their direct values are independent. However, if an array contains objects, those objects are not cloned, they are carried over by reference.
Object properties are where things get critical: PHP does not copy the object itself, only the pointer to it. After a simple clone, the original and the clone point to the very same instance of the nested object. A mutation on the inner object, whether performed through the original or through the clone, affects both at the same time. In most cases that is not the desired behavior.
A deep copy recursively clones all contained objects as well. In PHP you implement this by overriding the __clone() method. Inside it, every object property is explicitly given a clone treatment. If the inner object contains objects of its own, that object also needs a __clone() method for the deep-copy chain to be complete.
In Magento 2, most model objects (Product, Quote, Order) are instances of Magento\Framework\DataObject, which internally uses an array to hold all data. Since arrays are copied on a shallow copy, the simple data values are independent after a clone. Extension attributes, on the other hand, are objects, and here extra caution is warranted.
The Prototype Pattern in Magento 2: cloning quote items
The best-known example of the Prototype Pattern in Magento 2 lives in the cart system. When a configurable product is added to the cart, Magento internally creates two quote items: one for the configurable product itself (the parent item) and one for the concrete simple product variant (the child item). The child item is not fully initialized from scratch, it is created as a clone of the parent item.
This approach is a deliberate choice. The parent item already has all relevant properties set: quantity, quote reference, store scope, custom price and other fields. The child item is meant to inherit these values. Instead of manually transferring every value, Magento creates a clone and adjusts only the differing properties, namely the concrete product reference and the parent-child relationship.
The product type pool in Magento 2 also relies on the prototype concept. The class Magento\Catalog\Model\Product\Type holds prototype instances of the individual TypeModel classes (Simple, Configurable, Bundle, Virtual, Downloadable). On first access to a product type, the instance is created and cached. Every subsequent request receives the very same instance back. Since TypeModels are stateless, no cloning is necessary here, the prototype instance can be reused directly.
Another use case is batch import: when importing thousands of similar products, a fully configured base product object can serve as a prototype. For every row in the import, it is cloned, and only the SKU, price, and variant-specific attributes are overwritten. This saves the full object initialization, including all its default values, for every single record.
<?php
declare(strict_types=1);
namespace Mironsoft\Import\Model;
use Magento\Catalog\Api\Data\ProductInterface;
use Magento\Catalog\Api\Data\ProductInterfaceFactory;
/**
* Prototype-based product import processor.
* Clones a pre-configured base product for each import row.
*/
class ProductImportProcessor
{
private ?ProductInterface $prototype = null;
public function __construct(
private readonly ProductInterfaceFactory $productFactory,
private readonly array $defaultAttributes = []
) {}
/**
* Get or build the prototype product with shared default configuration.
*/
private function getPrototype(): ProductInterface
{
if ($this->prototype === null) {
$this->prototype = $this->productFactory->create();
$this->prototype->setTypeId('simple');
$this->prototype->setAttributeSetId(4);
$this->prototype->setStatus(1);
$this->prototype->setVisibility(4);
foreach ($this->defaultAttributes as $code => $value) {
$this->prototype->setData($code, $value);
}
}
// Prototype Pattern: clone instead of new for every import item
return clone $this->prototype;
}
/**
* Process a single import row using the cloned prototype.
*/
public function processRow(array $row): ProductInterface
{
$product = $this->getPrototype();
$product->setSku($row['sku']);
$product->setName($row['name']);
$product->setPrice((float) $row['price']);
return $product;
}
}
Why clone() instead of new: the performance perspective
Choosing clone over new is not a matter of elegance, it is a matter of performance. If an object's constructor runs database queries, calls external services, or performs expensive calculations, every new call repeats those costs. clone, on the other hand, is a pure memory operation: PHP copies the object's property table without running the constructor at all.
For simple PHP objects without expensive initialization, the difference is minimal. For Magento objects that are built via the DI container and, in the process, invoke factory classes, configuration readers and other dependent services, the difference can be measurable, especially in loops over thousands of records. Import jobs, reindex processes and batch CLI commands benefit from this the most.
There is another aspect worth noting: with clone you capture the current state of the prototype at a specific point in time. All default values, configuration parameters and shared properties are transferred in a single step. This reduces the risk of errors when setting values manually: whatever is forgotten is automatically present anyway, because it was inherited from the prototype.
Magento also applies this principle together with factories: a factory always creates a new instance with create(). When the factory internally works with a prototype and clones it instead of fully instantiating it, you combine a clean factory API with the efficiency of the Prototype Pattern. This pattern is used implicitly in several Magento core factories.
When clone() is dangerous: reference traps in Magento objects
The Prototype Pattern carries real risks if you ignore the shallow-copy nature of PHP's clone. Magento objects are especially prone to this because they frequently contain nested objects that get passed around as references. Extension attributes are a classic example: a ProductInterface can return an extension attribute object via getExtensionAttributes(). If the product is cloned without __clone(), the original and the clone share the very same extension attribute object.
It becomes particularly dangerous when the clone is passed to another part of the code and that code modifies the extension attribute object there. The change then silently affects the original as well. In a Magento system that processes thousands of requests, where observers, plugins and repositories access objects jointly, these kinds of reference bugs can cause problems that are extremely hard to debug.
Quote address objects, order items with product references, and custom data objects can be affected as well. Whenever you use clone on a Magento object, you should implement the __clone() method and explicitly clone every object property. It is more work, but considerably safer.
There is another risk with objects that carry a database identity: if you clone a persisted product and then save the clone, you either end up with a new product that has the same ID, or you have to explicitly reset the ID. The Prototype Pattern is therefore better suited to transient objects (quote items, temporary calculations) than to entities with a database identity.
<?php
declare(strict_types=1);
namespace Mironsoft\Catalog\Model;
use Magento\Catalog\Api\Data\ProductInterface;
use Magento\Catalog\Api\Data\ProductExtensionInterface;
/**
* Safe product cloning with explicit deep copy for nested objects.
*/
class SafeProductCloner
{
/**
* Create a deep clone of a product, including extension attributes.
*/
public function deepClone(ProductInterface $product): ProductInterface
{
$clone = clone $product;
// Shallow copy DANGER: extension attributes are shared!
// We must explicitly clone the extension attributes object.
$extAttributes = $product->getExtensionAttributes();
if ($extAttributes !== null) {
$clone->setExtensionAttributes(clone $extAttributes);
}
// Reset database identity, this is a new product, not an update
$clone->setId(null);
$clone->unsEntityId();
return $clone;
}
}
The Object Pool Pattern: concept and use cases
The Object Pool Pattern solves a different performance problem than the Prototype Pattern. It does not target expensive object initialization via a constructor, it targets the repeated creation of resource-intensive connections or handles. The core idea: instead of creating a new object every time you need one, you keep a stock (pool) of already-created objects on hand and take one from it whenever needed.
The classic example is database connections. Opening a new TCP connection to the database, performing the handshake, authenticating, and initializing the MySQL session can, depending on system load, take anywhere from 5 to 50 milliseconds. If every SQL query opened a new connection and closed it again afterward, a Magento request with hundreds of database operations would be massively slowed down. The connection pool fixes this: a connection is opened once and reused for all subsequent queries within the same request.
Other typical use cases for object pools include HTTP client instances for API calls to external services (ERP, PIM, payment providers), SFTP connections for file transfers, Redis client instances for cache operations, and Elasticsearch client connections. What all of these objects have in common is that building them is expensive, and they can be reused for repeated operations.
Unlike the Prototype Pattern, pool objects do not need to be returned after use, in a PHP request model (FPM) the pool ends with the request anyway. In PHP, object pools never survive across request boundaries, since every PHP-FPM worker has its own process and therefore its own memory. Object pools in PHP are therefore always request-scoped, which makes the implementation considerably simpler than in languages like Java or C# with shared-memory pools.
Object pools in Magento 2: shared instances and connection pooling
Magento 2 implements the Object Pool Pattern at two levels. The first and most comprehensive implementation is the DI container itself: injectable objects are managed as shared instances by default. The ObjectManager internally holds a $sharedInstances array that acts as an implicit object pool. The first call to get() for a given class type creates the instance and stores it in the pool. Every subsequent call returns that same instance.
This implicit object pool implementation is exactly why constructor injection in Magento 2 is so efficient. Services, repositories, loggers, session objects and every other injectable class are instantiated only once per request, no matter how many classes depend on them. The result is fewer objects in memory, fewer constructor calls and faster request processing.
The second, more explicit object pool implementation can be found in Magento\Framework\App\ResourceConnection. This class implements a genuine connection pool for database connections. It holds an internal $connections array that indexes connection instances by their $resourceName. By default, Magento has two connections: the main write connection (default) and the read connection (indexer). In larger setups with read replicas, additional connections are added.
The Elasticsearch and OpenSearch client in Magento 2 also relies on the pooling principle: a single client object is reused for all search requests and index operations within a request. The same applies to the Redis client in Magento\Framework\Cache\Backend\Redis, the connection is established once and used for all cache read and write operations.
// Magento\Framework\App\ResourceConnection, a simplified look at the connection pool
class ResourceConnection
{
/** @var \Magento\Framework\DB\Adapter\AdapterInterface[] the pool */
private array $connections = [];
/**
* Get or create a database connection for the given resource name.
* This is the Object Pool pattern: reuse instead of recreate.
*/
public function getConnection(string $resourceName = self::DEFAULT_CONNECTION): AdapterInterface
{
$connectionName = $this->config->getConnectionName($resourceName);
if (!isset($this->connections[$connectionName])) {
// First access: create and add to pool
$this->connections[$connectionName] = $this->connectionFactory->create(
$this->config->getConnectionConfig($connectionName)
);
}
// Subsequent access: return from pool, no new connection overhead
return $this->connections[$connectionName];
}
/**
* Close all pooled connections and clear the pool.
* Called at the end of the request lifecycle.
*/
public function closeConnection(string $resourceName = self::DEFAULT_CONNECTION): void
{
$connectionName = $this->config->getConnectionName($resourceName);
if (isset($this->connections[$connectionName])) {
$this->connections[$connectionName]->closeConnection();
unset($this->connections[$connectionName]);
}
}
}
Implementing your own object pool with PHP 8.4
For custom integrations, such as API connections to ERP systems, payment providers or external product databases, it is worth implementing your own object pool. The pattern is easy to implement in Magento 2: you create a class with an internal array as the pool, wired up via constructor injection with the necessary factories. Since the class itself is managed as a shared instance in the DI container, the pool survives the entire request.
A real-world use case: a Magento shop integrates several external supplier APIs. Every supplier has its own base URL and its own authentication. The HTTP client pool caches one client instance per supplier. The first request to a given supplier creates the client (connection setup, certificate handling, timeout configuration). Every further request reuses the same instance and saves the connection setup overhead.
The pool implementation should offer a release() method that lets individual pool entries be explicitly freed, for example when a connection is broken and needs to be rebuilt. A clear() method empties the entire pool, which is useful in tests and after error scenarios.
In PHP 8.4, an object pool can be written very concisely using readonly properties for the injected factories and typed generics comments (for IDEs and static analysis). Constructor property promotion significantly reduces boilerplate, and using declare(strict_types=1) prevents implicit type coercions in pool keys that can otherwise lead to hard-to-debug errors.
<?php
declare(strict_types=1);
namespace Mironsoft\Integration\Model\Pool;
use Magento\Framework\HTTP\ClientFactory;
use Magento\Framework\HTTP\ClientInterface;
use Psr\Log\LoggerInterface;
/**
* HTTP client pool, reuses one client instance per supplier base URL.
* Registered as shared="true" in di.xml (default for injectable classes).
*/
class HttpClientPool
{
/** @var array<string, ClientInterface> Pool indexed by base URL */
private array $pool = [];
public function __construct(
private readonly ClientFactory $clientFactory,
private readonly LoggerInterface $logger,
private readonly int $timeout = 30
) {}
/**
* Get or create an HTTP client for the given base URL.
* First call per base URL creates the client; subsequent calls reuse it.
*/
public function getClient(string $baseUrl): ClientInterface
{
if (!isset($this->pool[$baseUrl])) {
$this->logger->debug('HttpClientPool: creating new client', ['baseUrl' => $baseUrl]);
$client = $this->clientFactory->create();
$client->setTimeout($this->timeout);
$client->setHeaders([
'Accept' => 'application/json',
'Content-Type' => 'application/json',
]);
$this->pool[$baseUrl] = $client;
}
return $this->pool[$baseUrl];
}
/**
* Remove a specific client from the pool (e.g., after a connection error).
*/
public function release(string $baseUrl): void
{
unset($this->pool[$baseUrl]);
$this->logger->debug('HttpClientPool: released client', ['baseUrl' => $baseUrl]);
}
/**
* Clear all pooled clients, useful in test teardown or error recovery.
*/
public function clear(): void
{
$this->pool = [];
}
/**
* Get current pool size for monitoring/diagnostics.
*/
public function size(): int
{
return count($this->pool);
}
}
Prototype & Object Pool: the essentials at a glance
PHP clone() = shallow copy
Primitives and arrays are copied. Object properties are carried over by reference. For a deep copy: implement __clone() and explicitly clone every nested object.
Quote items: Prototype in action
Child items for configurable products are created as clone $parentItem. Only the differing properties (product, parent reference) are adjusted.
Shared instances = implicit pool
The DI container manages injectable objects as shared instances, which is effectively an object pool. The first instantiation places it in the pool, and every following injection reuses it.
Connection pool = explicit pool
ResourceConnection caches database connections by resourceName. Opened once, reused for every DB operation of the request.
Summary
The Prototype Pattern and the Object Pool Pattern pursue the same goal through different means: minimizing the cost of object creation. The Prototype Pattern achieves this by using clone instead of new, thereby avoiding expensive constructor calls. The Object Pool Pattern achieves it by keeping already created objects around and reusing them instead of discarding and recreating them.
In Magento 2.4.8, both patterns are deeply rooted in the framework. The Prototype Pattern shows up when cloning quote items and in import processes. The Object Pool Pattern is everywhere: the DI container itself is an object pool for all injectable services, and ResourceConnection is an explicit connection pool for database connections.
For your own Magento modules, the rule of thumb is: if you need expensive objects (HTTP clients, external connections) repeatedly, implement your own pool as a shared instance. If you create similar objects inside loops, consider the Prototype Pattern with careful deep-copy management. Both patterns pay into the same account: less overhead, faster requests and a more robust system.