Prototype Pattern in Magento 2: Extending Abstract Factory | Mironsoft
AI generated

Prototype Pattern: Extending Abstract Factory

· Reading time: approx. 11 minutes · Category: Magento 2 · Design Patterns

PRO
clone
Magento 2 · Deep Dive · Design Patterns

Prototype Pattern:
Extending Abstract Factory

When creating new objects is expensive, the Prototype Pattern copies an existing prototype instead. How Magento uses clone() and Factory::create(), and when you should apply it yourself.

⏱ 11 min. Deep Dive Design Patterns PHP 8.4

Copying instead of creating anew

The Prototype Pattern (GoF, 1994) solves a specific problem: sometimes creating a new object is expensive, involving heavy initialization, complex dependencies, or database queries in the constructor. In such cases it is more efficient to clone an already existing object and adjust the clone, rather than starting from scratch every time.

In PHP, the primary tool for this is clone $object. Magento uses the Prototype Pattern in several places, sometimes explicitly with clone, more often implicitly through its factory infrastructure.

1. The GoF Prototype Pattern

The Prototype Pattern states: create new objects by copying (cloning) a prototype object instead of directly instantiating a class:


<?php
// Classic Prototype Pattern:
interface Prototype
{
    public function clone(): static;
}

class ExpensiveObject implements Prototype
{
    private array $expensiveData;

    public function __construct()
    {
        // Expensive constructor: loads from DB, initializes complex structures
        $this->expensiveData = $this->loadFromDatabase();
    }

    private function loadFromDatabase(): array
    {
        sleep(1); // Simulates an expensive DB call
        return ['key' => 'value', /* ... 1000 entries ... */];
    }

    public function clone(): static
    {
        // PHP clone copies the object without running the constructor
        return clone $this;
    }
}

// Without Prototype: every new ExpensiveObject() = 1 second wait
$obj1 = new ExpensiveObject(); // 1 second
$obj2 = new ExpensiveObject(); // 1 second
$obj3 = new ExpensiveObject(); // 1 second

// With Prototype: only the first call is expensive
$prototype = new ExpensiveObject(); // 1 second
$obj1 = $prototype->clone();        // Instant (no constructor)
$obj2 = $prototype->clone();        // Instant
$obj3 = $prototype->clone();        // Instant

2. PHP clone: Shallow vs. Deep Copy

PHP's clone performs a shallow copy by default: primitive values and strings are copied, but object references remain the same:


<?php
class Address
{
    public function __construct(
        public string $street,
        public string $city
    ) {}
}

class Customer
{
    public function __construct(
        public string $name,
        public Address $address // Object reference!
    ) {}
}

$original = new Customer('Max Sample', new Address('Main Street 1', 'Berlin'));
$clone    = clone $original;

// Primitives are copied:
$clone->name = 'Maria Sample';
echo $original->name; // 'Max Sample' ✓ (unchanged)

// Object references are NOT copied:
$clone->address->city = 'Munich';
echo $original->address->city; // 'Munich' ✗ (changed!)
// Shallow copy: $original and $clone share the same Address object

// Solution: __clone() method for a deep copy
class Customer
{
    public function __construct(
        public string $name,
        public Address $address
    ) {}

    public function __clone(): void
    {
        // Deep copy: clone the Address too
        $this->address = clone $this->address;
    }
}

$clone2 = clone $original;
$clone2->address->city = 'Hamburg';
echo $original->address->city; // 'Berlin' ✓ (now unchanged)

3. Magento Factory: Prototype under the hood

Magento's generated factory classes are actually a form of the Prototype Pattern. When creating via Factory::create(), the DI container internally uses ObjectManager::create(), which is semantically equivalent to clone $prototype, but with DI container support:


<?php
// Generated factory (generated/code/Mironsoft/Blog/Model/PostFactory.php)
namespace Mironsoft\Blog\Model;

class PostFactory
{
    public function __construct(
        private readonly \Magento\Framework\ObjectManagerInterface $objectManager,
        private readonly string $instanceName = Post::class
    ) {}

    /**
     * Create new Post instance.
     * Internally: ObjectManager::create() (non-shared, new object).
     * Conceptually similar to: clone $prototype (but with full DI resolution)
     */
    public function create(array $data = []): Post
    {
        return $this->objectManager->create($this->instanceName, $data);
    }
}

// Usage: always use the factory instead of new or clone directly
class PostRepository
{
    public function __construct(
        private readonly PostFactory $postFactory
    ) {}

    public function getById(int $id): Post
    {
        $post = $this->postFactory->create(); // "Clone" of the prototype via DI
        $this->postResource->load($post, $id);
        return $post;
    }
}

4. Cloning DataObject: Magento's data carrier


<?php
// Magento DataObject is a universal key-value container
// Frequently cloned for "base data plus variations"

use Magento\Framework\DataObject;

// Prototype with base data:
$baseRateData = new DataObject([
    'carrier'       => 'flatrate',
    'carrier_title' => 'Flat Rate',
    'method_title'  => 'Fixed',
    'currency'      => 'EUR',
]);

// Variation A: standard delivery
$standard = clone $baseRateData;
$standard->setData('price', 4.99);
$standard->setData('cost', 3.50);
$standard->setData('method', 'standard');

// Variation B: express delivery (without reloading $baseRateData)
$express = clone $baseRateData;
$express->setData('price', 14.99);
$express->setData('cost', 9.00);
$express->setData('method', 'express');

// DataObject has no __clone(), a shallow copy is sufficient here,
// since DataObject only holds scalar values in the array.

5. Shipping rate cloning: a concrete Magento example

In the shipping cost system, Magento clones rate objects for different shipping methods of the same carrier:


<?php
// In Magento\Shipping\Model\Carrier\Flatrate::collectRates() (simplified):
namespace Magento\OfflineShipping\Model\Carrier;

class Flatrate extends AbstractCarrier
{
    public function collectRates(RateRequest $request): ?Result
    {
        $result = $this->_rateResultFactory->create();

        // Method object for "flatrate_flatrate":
        /** @var \Magento\Quote\Model\Quote\Address\RateResult\Method $method */
        $method = $this->_rateMethodFactory->create();
        $method->setCarrier($this->_code);
        $method->setCarrierTitle($this->getConfigData('title'));
        $method->setMethod($this->_code);
        $method->setMethodTitle($this->getConfigData('name'));
        $method->setPrice($this->getFinalPriceWithHandlingFee((float)$this->getConfigData('price')));
        $method->setCost((float)$this->getConfigData('price'));

        $result->append($method);

        // For multi-method carriers: clone base method, modify price:
        // $expressMethod = clone $method;
        // $expressMethod->setMethod('express');
        // $expressMethod->setPrice(14.99);
        // $result->append($expressMethod);

        return $result;
    }
}

6. Implementing your own Prototype Pattern


<?php
declare(strict_types=1);

namespace Mironsoft\Email\Model;

use Magento\Framework\Mail\MessageInterface;

/**
 * Email template prototype: base template that can be cloned
 * for different recipients without re-parsing the template.
 */
class EmailTemplate
{
    private string $parsedBody = '';
    private array $baseHeaders = [];

    public function __construct(
        private readonly TemplateParser $parser
    ) {}

    /**
     * Initialize the prototype with base template data.
     * Expensive: parses template, loads translations, etc.
     */
    public function initialize(string $templateId): void
    {
        $this->parsedBody   = $this->parser->parse($templateId); // Expensive!
        $this->baseHeaders  = $this->parser->getHeaders($templateId);
    }

    /**
     * Create a new email for a specific recipient.
     * Clones the prototype and customizes recipient-specific data.
     * Much cheaper than calling initialize() again.
     */
    public function createForRecipient(string $email, array $vars = []): static
    {
        $copy = clone $this; // No template re-parse needed!
        $copy->applyVariables($vars);
        $copy->setRecipient($email);
        return $copy;
    }

    private function applyVariables(array $vars): void
    {
        foreach ($vars as $key => $value) {
            $this->parsedBody = str_replace('{{' . $key . '}}', $value, $this->parsedBody);
        }
    }

    private function setRecipient(string $email): void
    {
        $this->baseHeaders['To'] = $email;
    }

    public function __clone(): void
    {
        // parsedBody and baseHeaders are arrays/strings, no deep copy needed
        // If there were object properties: clone them here
    }
}

// Usage: initialize once, clone N times
$template = new EmailTemplate($parser);
$template->initialize('newsletter_welcome'); // Expensive once

foreach ($recipients as $recipient) {
    $email = $template->createForRecipient(
        email: $recipient->getEmail(),
        vars: ['name' => $recipient->getName()]
    );
    $this->mailer->send($email);
}

7. When the Prototype Pattern makes sense


Prototype Pattern makes sense when:

✓ Object creation is expensive (DB queries, template parsing, complex calculations)
✓ Many similar objects with slight differences are needed
✓ The class hierarchy is not known at runtime (dynamic objects)
✓ Objects have many configuration parameters, only a few of which vary

Prototype Pattern does NOT make sense when:

✗ Object creation is cheap (simple value objects)
✗ Magento Factory::create() is sufficient (the DI container handles it correctly)
✗ Objects have complex object graphs (deep copy is error-prone)
✗ The differences between instances are too large

In Magento:
  Always prefer Factory::create() over clone directly
  Use clone only when the factory is not enough (e.g. rate variations)
  Never clone shared objects (singletons), it creates two "singletons"

8. Prototype vs. Factory: the difference

CriterionPrototype (clone)Factory (create)
BasisCopies an existing objectCreates a new object via DI
ConstructorNOT calledCalled (DI)
DependenciesInherited from the originalFreshly injected by the DI container
PerformanceFaster (no DI lookup)Slower (but usually sufficient)
In MagentoRare, specific casesStandard for non-shared objects

9. Testing the Prototype Pattern


<?php
class EmailTemplateTest extends \PHPUnit\Framework\TestCase
{
    public function testCloneIsIndependent(): void
    {
        $parser = $this->createMock(TemplateParser::class);
        $parser->method('parse')->willReturn('Hello {{name}}!');
        $parser->method('getHeaders')->willReturn(['From' => 'noreply@shop.de']);

        $template = new EmailTemplate($parser);
        $template->initialize('test_template');

        $email1 = $template->createForRecipient('alice@example.com', ['name' => 'Alice']);
        $email2 = $template->createForRecipient('bob@example.com', ['name' => 'Bob']);

        // Clones are independent of each other
        // Changing $email1 does not affect $email2
        $this->assertNotSame($email1, $email2);
        $this->assertNotSame($email1, $template); // Original unchanged
    }

    public function testParserCalledOnceNotForEachClone(): void
    {
        $parser = $this->createMock(TemplateParser::class);
        // parse() should only be called ONCE (for initialize())
        $parser->expects($this->once())->method('parse')->willReturn('Template');
        $parser->expects($this->once())->method('getHeaders')->willReturn([]);

        $template = new EmailTemplate($parser);
        $template->initialize('test_template');

        // Many clones, but parse() is not called again
        for ($i = 0; $i < 100; $i++) {
            $template->createForRecipient("user{$i}@example.com");
        }
    }
}

Mironsoft

Magento 2 Performance & Architecture

Performance optimization with design patterns?

We analyze Magento bottlenecks and implement performant solutions with Prototype, Object Pool, and other patterns, measurable, testable, and maintainable.

Performance audit
Identify bottlenecks in object creation, template parsing, and database access.
Pattern implementation
Prototype, Object Pool, Factory: the right pattern for each specific use case.
Benchmarking
Before/after comparison with Blackfire.io, documenting measurable performance improvements.

10. Summary

The Prototype Pattern is less dominant in Magento than Factory or Strategy, but valuable in specific scenarios: when objects are expensive to initialize and many similar instances are needed. PHP's clone operator performs shallow copies; for a deep copy, implement __clone(). In modern Magento code, Factory::create() is almost always preferable.

Prototype Pattern: key points

PHP clone

Shallow copy: primitives copied, object references shared. For a deep copy: implement __clone() and clone nested objects as well.

Magento Factory

Conceptually similar, but with full DI support. Factory::create() is the preferred method for new instances in Magento.

When to use

Useful when: initialization is expensive, many similar instances are needed. Not useful: simple objects, factory is sufficient, complex object graphs.

Caution

Never clone shared objects (violates singleton semantics). Always check whether a shallow copy is enough or whether __clone() is needed for a deep copy.

11. FAQ: Prototype Pattern in Magento 2

1 When clone instead of Factory::create()?
Factory::create() is almost always better. Use clone directly when: the factory is not available, many variations of an expensive prototype are needed, or the exact object state must be copied.
2 Shallow copy vs. deep copy?
Shallow: primitives copied, object references shared. Deep: all objects cloned as well, implemented via __clone().
3 Cloning Magento models?
Technically possible but not recommended, inconsistent states are possible. Instead: Factory::create() for a new instance, then copy data manually.
4 Implementing __clone() correctly?
Called after the clone operator. Clone all object properties as well: $this->address = clone $this->address;. Arrays with objects: foreach plus clone per element.
5 Where does Magento core use clone?
RateResult objects in shipping, DataObject variations, block variations in the template system. Indirectly: every Factory::create() is conceptually Prototype.
6 Cloning shared objects?
Never! The clone is not re-registered in the DI container, resulting in two instances of what should be a singleton, leading to inconsistent state.
7 DataObject for the Prototype Pattern?
Yes, DataObject only holds scalar values, so a shallow copy is sufficient. Create a base DataObject, clone it, set specific values. Typical use in the shipping system.
8 Testing prototype code?
Test focus areas: clone independent of the original (assertNotSame). Changes to the clone do not affect the original. Expensive initialization called only once (expects()->once()).
9 Performance: clone vs. new?
clone is usually faster than new plus DI lookup, but the difference is microseconds. Real gain: when the constructor contains expensive ops (DB, template parse), clone skips them entirely.
10 Defining Prototype with an interface?
Interface with a clone(): static method. Return type static for covariance in PHP 8+. Implementation usually: return clone $this; with optional __clone() logic.