PHP / Magento Dev Blog

  • Publikacje
  • O autorze
  • Kontakt

Memento pattern – undo/redo, price history, DB persistence as audit log

by Henryk Tews / Tuesday, 18 June 2024 / Published in Wzorce projektowe

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.

About Henryk Tews

What you can read next

Specification pattern – encapsulating business rules, AND/OR/NOT composition, testing
Proxy pattern – lazy loading, access control, caching, Proxy in Magento 2
Observer and Strategy in PHP – behavioural patterns

© 2026 Created by

TOP
Zarządzaj zgodą
Aby zapewnić jak najlepsze wrażenia, korzystamy z technologii, takich jak pliki cookie, do przechowywania i/lub uzyskiwania dostępu do informacji o urządzeniu. Zgoda na te technologie pozwoli nam przetwarzać dane, takie jak zachowanie podczas przeglądania lub unikalne identyfikatory na tej stronie. Brak wyrażenia zgody lub wycofanie zgody może niekorzystnie wpłynąć na niektóre cechy i funkcje.
Funkcjonalne Always active
Przechowywanie lub dostęp do danych technicznych jest ściśle konieczny do uzasadnionego celu umożliwienia korzystania z konkretnej usługi wyraźnie żądanej przez subskrybenta lub użytkownika, lub wyłącznie w celu przeprowadzenia transmisji komunikatu przez sieć łączności elektronicznej.
Preferencje
Przechowywanie lub dostęp techniczny jest niezbędny do uzasadnionego celu przechowywania preferencji, o które nie prosi subskrybent lub użytkownik.
Statystyka
Przechowywanie techniczne lub dostęp, który jest używany wyłącznie do celów statystycznych. Przechowywanie techniczne lub dostęp, który jest używany wyłącznie do anonimowych celów statystycznych. Bez wezwania do sądu, dobrowolnego podporządkowania się dostawcy usług internetowych lub dodatkowych zapisów od strony trzeciej, informacje przechowywane lub pobierane wyłącznie w tym celu zwykle nie mogą być wykorzystywane do identyfikacji użytkownika.
Marketing
Przechowywanie lub dostęp techniczny jest wymagany do tworzenia profili użytkowników w celu wysyłania reklam lub śledzenia użytkownika na stronie internetowej lub na kilku stronach internetowych w podobnych celach marketingowych.
  • Manage options
  • Manage services
  • Manage {vendor_count} vendors
  • Read more about these purposes
Zobacz preferencje
  • {title}
  • {title}
  • {title}