Коли Media Library у ICanUp почала використовуватися не в одному місці, проста модель "завантажити файл і зберегти його ID" перестала бути достатньою.
Одне зображення може стати обкладинкою Post, News або Post Series. Інше може використовуватися всередині контенту. А потім адміністратор може замінити його або видалити.
У цей момент головним стає не сам файл, а питання: чи знає система, де саме він використовується і що станеться з цими зв’язками після видалення?

Найпростіша реалізація працює лише до першого видалення
Для обкладинки легко почати з одного nullable foreign key:
$post->image_media_id = $media->id;$post->save();Поки Media ніколи не замінюється і не видаляється, цього може вистачати.
Але щойно з’являється повноцінна медіатека, виникає життєвий цикл:
upload | vselect | vuse in content | +-- replace | +-- soft delete | +-- permanent deletemedia_id не відповідає на питання, де використовується файл
Якщо дивитися тільки на таблицю Media, я знаю, що файл існує.
Але з цього не видно, чи він зараз використовується як:
- обкладинка Post;
- обкладинка News;
- обкладинка Post Series;
- зображення Content Block;
- інший підтримуваний контекст.
Саме тому в системі з’являється окреме поняття використання Media.
Я відокремила Media від його використання
Media | +-- Post / IMAGE | +-- News / IMAGE | +-- PostSeries / IMAGE | +-- ContentBlock / IMAGEMedia описує сам ресурс.
Usage описує факт, що конкретна сутність використовує цей ресурс у конкретному контексті.
Це різні речі, і саме це розділення робить заміну та видалення передбачуваними.
Під час вибору Media я не довіряю лише значенню з форми
Media Picker може показувати тільки зображення і не показувати видалені файли. Але це зручність інтерфейсу, а не серверна гарантія.
Запит можна змінити вручну, тому сервер повторно перевіряє Media.
$media = Media::query() ->findOrFail($mediaId); if (! $media->isImage()) { throw ValidationException::withMessages([ 'image_media_id' => 'Selected media must be an image.', ]);}Заміна зображення є зміною двох зв’язків
Коли обкладинка змінюється з Media A на Media B, недостатньо записати новий foreign key.
Для usage tracking це дві події:
Before: Post | +--> Media A After: Post | +--> Media B Media A usage: removeMedia B usage: addЯкщо оновити тільки image_media_id, а старий usage залишити, Media A назавжди виглядатиме використаним, хоча Post уже від нього не залежить.
Тому я синхронізую usage, а не просто додаю новий
У ICanUp цю відповідальність виконує окремий сервіс використання Media.
На рівні ідеї виклик виглядає так:
$changedMediaIds = $mediaUsageService ->syncSingleForContext( $post, $oldMediaId, $newMediaId, MediaUsageContextEnum::IMAGE, );Для мене тут важливе саме слово sync.
Сервіс отримує попередній та новий стан і може привести usage до фактичного зв’язку сутності.
Окремий сервіс прибирає Media-логіку з кожного модуля
Без спільного шару кожен модуль почав би сам вирішувати однакові задачі:
- як зареєструвати використання нового Media;
- як прибрати використання старого;
- як визначити контекст;
- як оновити стан Media після зміни зв’язків;
- як поводитися під час видалення.
Тоді Post, News і Post Series дуже швидко отримали б різні правила.
Я хочу, щоб модуль контенту повідомляв, що саме змінилося, а Media layer однаково обробляв сам usage.
Usage потрібен не тільки для видалення
Задача | Навіщо потрібен usage |
|---|---|
Видалення Media | Зрозуміти, які сутності можуть постраждати |
Заміна Media | Прибрати старий і створити новий зв’язок |
Адмінка | Показати, чи файл зараз використовується |
Аудит | Знайти, де саме використовується ресурс |
Очищення | Не видалити потрібний файл як нібито невикористаний |
Soft delete і permanent delete мають різний зміст
Для Media я розділяю тимчасове приховування ресурсу і його остаточне видалення.
Soft delete може означати, що Media більше не можна обирати для нового контенту, але запис ще існує і система може знати його історію та usage.
Permanent delete означає значно сильнішу операцію: після неї посилання на цей Media ID більше не повинні залишатися активними.
Permanent delete не повинен залишати dangling foreign key
Найгірший результат виглядає так:
news.image_media_id = 123 media.id = 123 | X permanently deleted Result:News still points to media 123,but media 123 no longer exists.Тому остаточне видалення повинно враховувати відомі зв’язки.
Для nullable обкладинки безпечним результатом може бути очищення reference перед або в межах операції видалення.
$news->image_media_id = null;$news->save();Foreign key допомагає, але не описує весь Media usage
Database foreign key дуже корисний для прямого поля на кшталт image_media_id.
Наприклад, nullable FK із відповідною поведінкою при видаленні може захистити від частини зламаних посилань.
Але повна Media Library часто має більше типів використання, ніж одна колонка в одній таблиці.
- прямі foreign keys;
- зображення всередині Content Blocks;
- кілька Media в одному контексті;
- поліморфні зв’язки;
- майбутні модулі, які теж використовуватимуть Media.
Тому database constraints і usage tracking доповнюють одне одного, а не замінюють.
Видалення файла і видалення Media record теж не одне й те саме
Є ще один рівень: фізичний файл у storage.
Якщо спочатку видалити файл, а потім операція очищення зв’язків впаде, можна отримати Media record, який формально існує, але його файл уже відсутній.
Тому порядок кроків під час permanent delete повинен бути визначений так само чітко, як і робота з базою.
1. Resolve usages2. Apply deletion policy3. Clean references4. Remove Media record5. Remove physical file at the safe pointПодія після commit корисна для похідного стану Media
Після зміни usage іншим частинам системи може знадобитися оновити похідний стан або кешовану інформацію.
Я не хочу повідомляти про успішну зміну раніше, ніж транзакція бази даних справді завершилася.
DB::afterCommit( static function () use ( $mediaUsageDispatcher, $changedMediaIds, ): void { $mediaUsageDispatcher->dispatch( array_values( array_unique($changedMediaIds) ) ); });Це зменшує ризик ситуації, коли зовнішня реакція вже відбулася, а сама транзакція пізніше була відкотилася.
Media Picker і сервер мають різні обов’язки
Media Picker | Laravel backend |
|---|---|
Показує зручний список | Перевіряє, що Media реально існує |
Фільтрує типи | Перевіряє допустимий тип |
Ховає видалене | Не приймає видалене Media |
Допомагає вибрати файл | Захищає доменний контракт |
UI може допомогти зробити правильний вибір. Сервер повинен не дозволити неправильний.
Я хочу знати про використання до натискання Delete
Usage tracking корисний ще до виконання destructive operation.
Якщо Media використовується в кількох місцях, адмінка може показати це до остаточного видалення.
Media: architecture-cover.png Used by:- Post cover- News cover- Post Series coverТоді видалення перестає бути сліпою дією.
Адміністратор бачить, що ресурс має залежності, а backend усе одно залишається останнім рівнем захисту.
Невикористаний Media теж потрібно визначати надійно
З часом Media Library накопичує файли, які вже ніде не використовуються.
Але видаляти їх за принципом "я не бачу foreign key у Posts" небезпечно.
Файл може використовуватися News, серією або іншим типом контенту.
Саме usage layer дозволяє визначити невикористаний ресурс на рівні всієї системи, а не одного модуля.
Тести повинні перевіряти весь життєвий цикл
Сценарій | Що я очікую |
|---|---|
Вибрано коректне image Media | Зв’язок і usage створені |
Media не існує | Запит відхиляється |
Media видалене | Новий зв’язок не створюється |
Media не є зображенням | Для image context запит відхиляється |
Обкладинку замінено | Старий usage прибраний, новий доданий |
Обкладинку очищено | Usage старого Media прибраний |
Permanent delete | Не залишається активного broken reference |
Однакове Media використовується кількома сутностями | Видалення одного usage не прибирає інші |
Один Media може мати більше одного usage
Це окремо важливо.
Якщо одне зображення одночасно використовується у двох матеріалах, заміна обкладинки першого не означає, що Media стало невикористаним.
Media 75 | +-- Post 18 / IMAGE | +-- News 4 / IMAGE Remove Post 18 usage | v Media 75 is still used by News 4Що я не роблю з Media references
- Не довіряю Media ID лише тому, що його надіслав Media Picker.
- Не дозволяю deleted Media використовувати в новому контенті.
- Не дозволяю файл неправильного типу використовувати як зображення.
- Не додаю новий usage, не прибравши старий під час заміни.
- Не вважаю Media невикористаним після перевірки лише одного модуля.
- Не видаляю фізичний файл без урахування стану запису та його залежностей.
- Не залишаю permanent delete без визначеної політики для references.
- Не покладаю всю цілісність Media Library лише на клієнтський інтерфейс.
Мій короткий checklist для Media Usage
- Відокремити Media asset від факту його використання.
- Визначити підтримувані usage contexts.
- Перевіряти існування Media на сервері.
- Перевіряти тип Media відповідно до контексту.
- Не дозволяти нове використання видаленого Media.
- Синхронізувати старий і новий usage під час заміни.
- Правильно обробляти очищення nullable Media field.
- Розрізняти soft delete і permanent delete.
- Перед остаточним видаленням враховувати всі відомі usages.
- Не залишати dangling foreign keys або інші зламані references.
- Не вважати файл невикористаним, поки не перевірена вся Media Usage система.
- Покрити заміну, видалення і multiple usages автоматичними тестами.
Висновок
У ICanUp Media Library перестала бути просто сховищем файлів у той момент, коли одне й те саме Media почали використовувати різні типи контенту.
Зберегти media_id легко. Значно важливіше знати, хто зараз залежить від цього Media, як змінити цей зв’язок під час заміни і що має статися під час видалення.
Тому я відокремлюю сам Media asset від його usage, повторно перевіряю вибране Media на сервері та синхронізую старий і новий зв’язок через спільний сервіс.
Soft delete і permanent delete мають різні наслідки, а остаточне видалення не повинно залишати активні посилання на ресурс, якого більше не існує.
Для мене надійна Media Library починається не з upload. Вона починається з відповіді на питання: де використовується цей файл і що станеться з усіма цими місцями, якщо його змінити або видалити.



