PHP / Magento Dev Blog

  • Publikacje
  • O autorze
  • Kontakt

Wzorzec Memento – undo/redo, historia zmian cen, persystencja do bazy jako audit log

by Henryk Tews / wtorek, 18 czerwca 2024 / Opublikowano w Wzorce projektowe

Memento to wzorzec behawioralny który pozwala zapisywać i przywracać poprzedni stan obiektu bez ujawniania szczegółów jego implementacji. Undo/redo w edytorze tekstowym, historia zmian cen produktu, rollback konfiguracji – wszędzie tam gdzie chcesz cofnąć operację, Memento daje eleganckie rozwiązanie. Implementuję od zera z przykładami z e-commerce.

Trzy elementy wzorca

  • Originator – obiekt którego stan chcemy zapisywać i przywracać
  • Memento – snapshotter stanu Originatora – niemodyfikowalny, enkapsuluje stan
  • Caretaker – zarządza historią Memento (stos, lista)

Implementacja – historia cen produktu

<?php

declare(strict_types=1);

// Memento - snapshot stanu produktu
// Immutable - raz stworzony nie może być zmieniony
final class ProductPriceMemento
{
    private readonly \DateTimeImmutable $createdAt;

    public function __construct(
        private readonly float $regularPrice,
        private readonly ?float $specialPrice,
        private readonly ?string $specialPriceFrom,
        private readonly ?string $specialPriceTo,
        private readonly array $tierPrices,
        private readonly string $changedBy,
        private readonly string $reason
    ) {
        $this->createdAt = new \DateTimeImmutable();
    }

    public function getRegularPrice(): float      { return $this->regularPrice; }
    public function getSpecialPrice(): ?float     { return $this->specialPrice; }
    public function getSpecialPriceFrom(): ?string { return $this->specialPriceFrom; }
    public function getSpecialPriceTo(): ?string   { return $this->specialPriceTo; }
    public function getTierPrices(): array         { return $this->tierPrices; }
    public function getChangedBy(): string         { return $this->changedBy; }
    public function getReason(): string            { return $this->reason; }
    public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; }
}

// Originator - produkt z obsługą historii cen
class PricedProduct
{
    private float $regularPrice;
    private ?float $specialPrice = null;
    private ?string $specialPriceFrom = null;
    private ?string $specialPriceTo = null;
    private array $tierPrices = [];

    public function __construct(
        private readonly string $sku,
        float $regularPrice
    ) {
        $this->regularPrice = $regularPrice;
    }

    // Zapisz bieżący stan jako Memento
    public function save(string $changedBy, string $reason = ''): ProductPriceMemento
    {
        return new ProductPriceMemento(
            regularPrice:     $this->regularPrice,
            specialPrice:     $this->specialPrice,
            specialPriceFrom: $this->specialPriceFrom,
            specialPriceTo:   $this->specialPriceTo,
            tierPrices:       $this->tierPrices,
            changedBy:        $changedBy,
            reason:           $reason
        );
    }

    // Przywróć stan z Memento
    public function restore(ProductPriceMemento $memento): void
    {
        $this->regularPrice     = $memento->getRegularPrice();
        $this->specialPrice     = $memento->getSpecialPrice();
        $this->specialPriceFrom = $memento->getSpecialPriceFrom();
        $this->specialPriceTo   = $memento->getSpecialPriceTo();
        $this->tierPrices       = $memento->getTierPrices();
    }

    // Operacje modyfikujące stan
    public function setRegularPrice(float $price): void
    {
        if ($price <= 0) {
            throw new \InvalidArgumentException('Price must be positive');
        }
        $this->regularPrice = $price;
    }

    public function setSpecialPrice(float $price, string $from, string $to): void
    {
        $this->specialPrice     = $price;
        $this->specialPriceFrom = $from;
        $this->specialPriceTo   = $to;
    }

    public function addTierPrice(int $qty, float $price): void
    {
        $this->tierPrices[] = ['qty' => $qty, 'price' => $price];
        // Posortuj po ilości
        usort($this->tierPrices, fn($a, $b) => $a['qty'] <=> $b['qty']);
    }

    public function removeSpecialPrice(): void
    {
        $this->specialPrice     = null;
        $this->specialPriceFrom = null;
        $this->specialPriceTo   = null;
    }

    public function getRegularPrice(): float      { return $this->regularPrice; }
    public function getSpecialPrice(): ?float     { return $this->specialPrice; }
    public function getSku(): string              { return $this->sku; }
    public function getTierPrices(): array        { return $this->tierPrices; }
}
<?php

declare(strict_types=1);

// Caretaker - zarządza historią Memento
class PriceHistory
{
    /** @var ProductPriceMemento[] */
    private array $history = [];
    private int $currentIndex = -1;
    private int $maxHistory;

    public function __construct(int $maxHistory = 50)
    {
        $this->maxHistory = $maxHistory;
    }

    // Zapisz nowy stan (usuwa "przyszłość" jeśli cofnęliśmy się i zrobiliśmy nową zmianę)
    public function push(ProductPriceMemento $memento): void
    {
        // Usuń wszystkie stany "po" aktualnym (redo history)
        $this->history = array_slice($this->history, 0, $this->currentIndex + 1);

        $this->history[] = $memento;
        $this->currentIndex++;

        // Ogranicz historię
        if (count($this->history) > $this->maxHistory) {
            array_shift($this->history);
            $this->currentIndex--;
        }
    }

    // Cofnij (undo) - zwróć poprzedni stan
    public function undo(): ?ProductPriceMemento
    {
        if ($this->currentIndex <= 0) {
            return null; // nic do cofnięcia
        }

        $this->currentIndex--;
        return $this->history[$this->currentIndex];
    }

    // Ponów (redo) - zwróć następny stan
    public function redo(): ?ProductPriceMemento
    {
        if ($this->currentIndex >= count($this->history) - 1) {
            return null; // nic do ponowienia
        }

        $this->currentIndex++;
        return $this->history[$this->currentIndex];
    }

    // Sprawdź możliwości
    public function canUndo(): bool { return $this->currentIndex > 0; }
    public function canRedo(): bool { return $this->currentIndex < count($this->history) - 1; }

    // Historia zmian do audytu
    public function getChangeLog(): array
    {
        return array_map(fn(ProductPriceMemento $m) => [
            'price'      => $m->getRegularPrice(),
            'special'    => $m->getSpecialPrice(),
            'changed_by' => $m->getChangedBy(),
            'reason'     => $m->getReason(),
            'date'       => $m->getCreatedAt()->format('Y-m-d H:i:s'),
        ], $this->history);
    }
}
<?php

// Użycie - historia zmian cen produktu
$product = new PricedProduct('SKU-WIDGET-PRO', 29.99);
$history = new PriceHistory();

// Zapisz stan początkowy
$history->push($product->save('system', 'Initial price'));

// Zmiana 1 - podwyżka
$product->setRegularPrice(34.99);
$history->push($product->save('admin@example.com', 'Price increase Q2 2024'));

// Zmiana 2 - promocja
$product->setSpecialPrice(27.99, '2024-06-01', '2024-06-30');
$history->push($product->save('marketing@example.com', 'Summer sale promotion'));

// Zmiana 3 - ceny hurtowe
$product->addTierPrice(10, 31.49);
$product->addTierPrice(50, 27.99);
$history->push($product->save('b2b@example.com', 'B2B tier prices added'));

echo "Aktualna cena: " . $product->getRegularPrice() . " PLN\n"; // 34.99
echo "Cena promo: " . ($product->getSpecialPrice() ?? 'brak') . "\n"; // 27.99

// Cofnij zmianę 3 (tier prices)
if ($history->canUndo()) {
    $memento = $history->undo();
    $product->restore($memento);
    echo "Po cofnięciu - tier prices: " . count($product->getTierPrices()) . "\n"; // 0
}

// Cofnij zmianę 2 (promocja)
if ($history->canUndo()) {
    $memento = $history->undo();
    $product->restore($memento);
    echo "Po cofnięciu - cena promo: " . ($product->getSpecialPrice() ?? 'brak') . "\n"; // brak
}

// Ponów zmianę 2 (przywróć promocję)
if ($history->canRedo()) {
    $memento = $history->redo();
    $product->restore($memento);
    echo "Po ponowieniu - cena promo: " . ($product->getSpecialPrice() ?? 'brak') . "\n"; // 27.99
}

// Audit log wszystkich zmian
echo "\nHistoria zmian:\n";
foreach ($history->getChangeLog() as $entry) {
    echo "  {$entry['date']} | {$entry['changed_by']} | {$entry['price']} PLN | {$entry['reason']}\n";
}

Memento z persystencją w bazie danych

<?php

declare(strict_types=1);

// Serializacja Memento do bazy - historia zmian trwała między requestami
class PriceHistoryRepository
{
    public function __construct(private \PDO $pdo) {}

    public function save(string $sku, ProductPriceMemento $memento): void
    {
        $this->pdo->prepare('
            INSERT INTO price_history (sku, regular_price, special_price, tier_prices, changed_by, reason, created_at)
            VALUES (:sku, :regular, :special, :tiers, :by, :reason, :ts)
        ')->execute([
            ':sku'     => $sku,
            ':regular' => $memento->getRegularPrice(),
            ':special' => $memento->getSpecialPrice(),
            ':tiers'   => json_encode($memento->getTierPrices()),
            ':by'      => $memento->getChangedBy(),
            ':reason'  => $memento->getReason(),
            ':ts'      => $memento->getCreatedAt()->format('Y-m-d H:i:s'),
        ]);
    }

    public function getHistory(string $sku, int $limit = 20): array
    {
        $stmt = $this->pdo->prepare('
            SELECT * FROM price_history WHERE sku = :sku
            ORDER BY created_at DESC LIMIT :limit
        ');
        $stmt->bindValue(':sku',   $sku);
        $stmt->bindValue(':limit', $limit, \PDO::PARAM_INT);
        $stmt->execute();

        return array_map(function(array $row) {
            return new ProductPriceMemento(
                regularPrice:     (float) $row['regular_price'],
                specialPrice:     $row['special_price'] ? (float) $row['special_price'] : null,
                specialPriceFrom: null,
                specialPriceTo:   null,
                tierPrices:       json_decode($row['tier_prices'], true) ?? [],
                changedBy:        $row['changed_by'],
                reason:           $row['reason']
            );
        }, $stmt->fetchAll(\PDO::FETCH_ASSOC));
    }
}

Podsumowanie

Memento rozwiązuje problem zapisywania i przywracania stanu w elegancki sposób – Caretaker zarządza historią nie wiedząc nic o wewnętrznej strukturze Originatora. W e-commerce najczęstsze zastosowania to historia zmian cen, historia konfiguracji modułów, i mechanizm undo w panelu admina. Połączenie Memento z persystencją w bazie daje audit trail który jest wymagany przy zgodności z regulacjami (kto zmienił cenę, kiedy i dlaczego).

About Henryk Tews

Co możesz przeczytać następne

Wzorce GoF w Magento 2 – gdzie je znaleźć i jak działają
Wzorzec Observer w PHP i system zdarzeń Magento 2
Wzorzec Visitor – double dispatch, eksport CSV/PDF, walidacja bez modyfikacji klas
  • Publikacje
  • O autorze
  • Kontakt

© 2026 Created by

GÓRA
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 Zawsze aktywne
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.
  • Zarządzaj opcjami
  • Zarządzaj serwisami
  • Zarządzaj {vendor_count} dostawcami
  • Przeczytaj więcej o tych celach
Zobacz preferencje
  • {title}
  • {title}
  • {title}