WeakReference (PHP 7.4+) і WeakMap (PHP 8.0+) тримають обʼєкт, не збільшуючи його refcount: як тільки обʼєкт знищується, посилання стає null, а запис у WeakMap зникає сам — це штатний спосіб робити привʼязані до обʼєктів кеші й реєстри в довгоживучих процесах.
Основа памʼяті в PHP — підрахунок посилань: кожен обʼєкт має refcount, і щойно він падає до нуля, обʼєкт знищується негайно, з викликом __destruct(). Будь-яка звичайна змінна, властивість чи елемент масиву, що вказує на обʼєкт, цей лічильник збільшує — тобто утримує обʼєкт живим. Слабке посилання — це виняток із правила: WeakReference::create($obj) (PHP 7.4+) запамʼятовує обʼєкт, не чіпаючи refcount. Поки на обʼєкт є хоч одне сильне посилання, $weak->get() повертає його; коли останнє зникає, обʼєкт знищується за звичайним сценарієм, а get() починає повертати null. До 7.4 це вміла лише PECL-розширення weakref, тож на старих проєктах ви цього API не побачите.
WeakMap (PHP 8.0+) — надбудова над тією ж ідеєю: мапа, де ключ — обʼєкт (тільки обʼєкт, скаляр дасть TypeError), значення — будь-що, і слабким є саме ключ. Клас реалізує ArrayAccess, Countable та IteratorAggregate, тому працює як звичайний масив: $map[$obj] = $data, isset($map[$obj]), count($map), foreach ($map as $obj => $data). Головна властивість — запис видаляється автоматично разом зі знищенням ключа, без жодного коду очищення з вашого боку. Це те, чого не дають альтернативи: SplObjectStorage теж мапить обʼєкти на дані, але тримає ключі сильно, а масив, індексований spl_object_id(), обʼєкт не тримає, зате накопичує мертві записи й ризикує колізією — ідентифікатор знищеного обʼєкта PHP може видати новому, і кеш віддасть чужі дані.
Практичний сенс зʼявляється там, де процес живе довго: Octane, RoadRunner, Swoole, queue:work у режимі демона, довгі CLI-команди, ReactPHP/Amp. У класичному FPM-запиті все одно все звільняється наприкінці, тому витік у пер-обʼєктному кеші там непомітний; у демоні той самий статичний масив росте від першого запиту до перезапуску воркера. Канонічний сценарій для WeakMap — метадані, привʼязані до обʼєкта і не потрібні довше за нього: обчислені права користувача, memoized-результат дорогого методу для конкретної сутності, стан гідратації або ознака «цей обʼєкт уже провалідовано» у межах одного job. У прикладі PermissionResolver кеш живе рівно стільки, скільки живе $user: count() сам падає до нуля, коли воркер відпускає сутність.
Слабкість односпрямована, і на цьому найчастіше спотикаються. WeakMap тримає значення сильно, тому будь-який шлях від значення назад до ключа перетворює слабкий запис на вічний: $map[$order] = ['owner' => $order] з останнього блоку коду означає ланцюжок WeakMap → масив → $order, і unset($order) уже нічого не змінює. Лікується це зберіганням даних без зворотного посилання або загорнутим WeakReference усередині значення. Друга межа — цикли: якщо обʼєкт-ключ входить у циклічний граф (батько тримає дитину, дитина тримає батька), refcount ніколи не досягне нуля сам, і запис зникне лише після проходу збирача циклів, тож у тестах перед перевіркою count() варто викликати gc_collect_cycles(). Третя — обидва класи не серіалізуються: serialize() на них кине виняток, тому в сесію, кеш чи payload черги вони не потрапляють.
Нарешті, варто чесно назвати, чим WeakMap не є. Це не кеш із витісненням: у нього немає ані TTL, ані ліміту розміру, ані LRU — єдиний критерій видалення це смерть ключа. Якщо обʼєкт живе весь процес (синглтон із контейнера, case enum, зареєстрований слухач), WeakMap не дасть нічого, крім зайвої непрямості, і звичайна властивість буде чеснішою. І це не заміна аналізу витоків: якщо памʼять воркера росте, спершу треба знайти, хто тримає посилання (php-memprof, підрахунок живих обʼєктів у контрольних точках, memory_get_usage(true) і gc_status() у метриках), і лише потім вирішувати, чи справді проблемне місце — це реєстр, якому слабких ключів вистачить.
final class PermissionResolver
{
/** @var WeakMap<User, list<string>> ключ тримається слабко */
private WeakMap $cache;
public function __construct(private readonly PermissionStorage $storage)
{
$this->cache = new WeakMap; // PHP 8.0+
}
/** @return list<string> */
public function for(User $user): array
{
// ??= працює через ArrayAccess: offsetExists → offsetGet → offsetSet
return $this->cache[$user] ??= $this->storage->load($user->id);
}
public function cachedCount(): int
{
return count($this->cache); // WeakMap реалізує Countable
}
}
$resolver = new PermissionResolver($storage);
$user = new User(42);
$resolver->for($user);
echo $resolver->cachedCount(); // 1
// WeakReference (PHP 7.4+) не інкрементує refcount; new заборонено
$weak = WeakReference::create($user);
unset($user); // зникло останнє сильне посилання
var_dump($weak->get()); // NULL — обʼєкт уже знищено
echo $resolver->cachedCount(); // 0 — запис пішов разом із ключем
// Пастка: значення тримає ключ, слабкість працює лише в один бік
$map = new WeakMap;
$order = new Order;
$map[$order] = ['owner' => $order]; // WeakMap → значення → ключ
unset($order);
echo count($map); // 1 — памʼять не звільниться
Формулюйте через refcount: «сильне посилання каже обʼєкту жити, слабке — лише питає, чи він ще живий». Далі одне речення практики: у довгому воркері метадані, привʼязані до обʼєкта, тримають у WeakMap, бо масив за `spl_object_id` там перетворюється на витік із перевикористаними ключами.