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).
