Memento is a behavioural pattern that captures and restores an object’s internal state without violating encapsulation. It is the foundation of undo/redo, version history, and audit logs. I show the classic GoF implementation, a practical price history tracker for Magento, and a database-persisted version that doubles as an audit log.
Classic Memento pattern
<?php
declare(strict_types=1);
// The Memento - snapshot of state, opaque to everyone except its creator
final class ProductMemento
{
public function __construct(
private float $price,
private string $name,
private int $status,
private \DateTimeImmutable $capturedAt,
) {}
// Only the Product (Originator) can read the memento's internals
public function getPrice(): float { return $this->price; }
public function getName(): string { return $this->name; }
public function getStatus(): int { return $this->status; }
public function getCapturedAt(): \DateTimeImmutable { return $this->capturedAt; }
}
// Originator - creates and restores mementos
class Product
{
public function __construct(
private float $price,
private string $name,
private int $status = 1,
) {}
public function getPrice(): float { return $this->price; }
public function getName(): string { return $this->name; }
public function getStatus(): int { return $this->status; }
public function setPrice(float $price): void { $this->price = $price; }
public function setName(string $name): void { $this->name = $name; }
public function setStatus(int $status): void { $this->status = $status; }
// Create a snapshot of current state
public function save(): ProductMemento
{
return new ProductMemento(
$this->price,
$this->name,
$this->status,
new \DateTimeImmutable()
);
}
// Restore from a snapshot
public function restore(ProductMemento $memento): void
{
$this->price = $memento->getPrice();
$this->name = $memento->getName();
$this->status = $memento->getStatus();
}
}
// Caretaker - manages the history stack, never reads memento internals
class ProductHistory
{
private \SplStack $history;
public function __construct()
{
$this->history = new \SplStack();
}
public function saveState(Product $product): void
{
$this->history->push($product->save());
}
public function undo(Product $product): bool
{
if ($this->history->isEmpty()) return false;
$product->restore($this->history->pop());
return true;
}
public function hasHistory(): bool { return !$this->history->isEmpty(); }
}
// Usage
$product = new Product(99.99, 'Widget');
$history = new ProductHistory();
$history->saveState($product); // save: 99.99
$product->setPrice(79.99); // change
$history->saveState($product); // save: 79.99
$product->setPrice(59.99); // change
echo $product->getPrice(); // 59.99
$history->undo($product);
echo $product->getPrice(); // 79.99
$history->undo($product);
echo $product->getPrice(); // 99.99
Price history with database persistence
<?php
declare(strict_types=1);
// Persisted Memento - stored in DB as audit log
class PriceHistoryEntry
{
public function __construct(
public readonly int $productId,
public readonly float $oldPrice,
public readonly float $newPrice,
public readonly int $adminUserId,
public readonly string $reason,
public readonly \DateTimeImmutable $changedAt,
public readonly ?int $id = null,
) {}
}
class PriceHistoryRepository
{
public function __construct(
private \Magento\Framework\App\ResourceConnection $resourceConnection
) {}
public function save(PriceHistoryEntry $entry): int
{
$connection = $this->resourceConnection->getConnection();
$connection->insert(
$this->resourceConnection->getTableName('vendor_product_price_history'),
[
'product_id' => $entry->productId,
'old_price' => $entry->oldPrice,
'new_price' => $entry->newPrice,
'admin_user_id' => $entry->adminUserId,
'reason' => $entry->reason,
'changed_at' => $entry->changedAt->format('Y-m-d H:i:s'),
]
);
return (int) $connection->lastInsertId();
}
/** @return PriceHistoryEntry[] */
public function getHistory(int $productId, int $limit = 20): array
{
$connection = $this->resourceConnection->getConnection();
$select = $connection->select()
->from($this->resourceConnection->getTableName('vendor_product_price_history'))
->where('product_id = ?', $productId)
->order('changed_at DESC')
->limit($limit);
return array_map(fn($row) => new PriceHistoryEntry(
productId: (int) $row['product_id'],
oldPrice: (float) $row['old_price'],
newPrice: (float) $row['new_price'],
adminUserId: (int) $row['admin_user_id'],
reason: $row['reason'],
changedAt: new \DateTimeImmutable($row['changed_at']),
id: (int) $row['id'],
), $connection->fetchAll($select));
}
public function getLatestEntry(int $productId): ?PriceHistoryEntry
{
$history = $this->getHistory($productId, 1);
return $history[0] ?? null;
}
}
// Service that uses Memento for price changes
class PriceChangeService
{
public function __construct(
private \Magento\Catalog\Api\ProductRepositoryInterface $productRepository,
private PriceHistoryRepository $historyRepository
) {}
public function changePrice(
int $productId,
float $newPrice,
int $adminId,
string $reason
): void {
$product = $this->productRepository->getById($productId);
$oldPrice = (float) $product->getPrice();
if (abs($oldPrice - $newPrice) < 0.001) return; // no change
// Save the memento (audit entry) BEFORE changing
$this->historyRepository->save(new PriceHistoryEntry(
productId: $productId,
oldPrice: $oldPrice,
newPrice: $newPrice,
adminUserId: $adminId,
reason: $reason,
changedAt: new \DateTimeImmutable(),
));
$product->setPrice($newPrice);
$this->productRepository->save($product);
}
public function revertLastChange(int $productId, int $adminId): bool
{
$lastEntry = $this->historyRepository->getLatestEntry($productId);
if ($lastEntry === null) return false;
// Restore previous price - using the memento
$this->changePrice($productId, $lastEntry->oldPrice, $adminId, 'Reverted: ' . $lastEntry->reason);
return true;
}
}
Summary
Memento captures state before it changes. The clean separation between Originator (who owns the state), Memento (the opaque snapshot), and Caretaker (who stores the snapshots) prevents implementation details from leaking. The database-persisted version serves a dual purpose: undo functionality and compliance audit log. In Magento 2 this pattern is natural for price changes, product status history, and any admin action that needs to be traceable and reversible.
