0% прочитано

Laravel Media Usage: як не залишати зламані посилання після видалення зображення

У ICanUp одне Media може використовуватися як обкладинка Post, News, Post Series або в інших частинах контенту. Простого media_id виявляється недостатньо, коли файл замінюють або видаляють. Показую, навіщо я відстежую використання Media, повторно перевіряю його на сервері, синхронізую старий і новий зв’язок та не дозволяю остаточному видаленню залишати зламані посилання.

18 вересня 2026 р. 10 хв читанняЛаравель

Коли Media Library у ICanUp почала використовуватися не в одному місці, проста модель "завантажити файл і зберегти його ID" перестала бути достатньою.

Одне зображення може стати обкладинкою Post, News або Post Series. Інше може використовуватися всередині контенту. А потім адміністратор може замінити його або видалити.

У цей момент головним стає не сам файл, а питання: чи знає система, де саме він використовується і що станеться з цими зв’язками після видалення?

Надійна Media Library повинна знати не тільки які файли існують, а й які сутності зараз від них залежать.
Laravel Media Usage flow showing one Media asset referenced by Post, News, Post Series and Content Blocks, with usage tracking protecting references during replacement and deletion
Одне Media може мати кілька контекстів використання. Під час заміни або видалення система повинна оновити не тільки файл, а й зв’язки з контентом.

Найпростіша реалізація працює лише до першого видалення

Для обкладинки легко почати з одного nullable foreign key:

PHP - Зберегти Media як обкладинку
$post->image_media_id = $media->id;$post->save();

Поки Media ніколи не замінюється і не видаляється, цього може вистачати.

Але щойно з’являється повноцінна медіатека, виникає життєвий цикл:

Життєвий цикл Media
upload  |  vselect  |  vuse in content  |  +-- replace  |  +-- soft delete  |  +-- permanent delete

media_id не відповідає на питання, де використовується файл

Якщо дивитися тільки на таблицю Media, я знаю, що файл існує.

Але з цього не видно, чи він зараз використовується як:

  • обкладинка Post;
  • обкладинка News;
  • обкладинка Post Series;
  • зображення Content Block;
  • інший підтримуваний контекст.

Саме тому в системі з’являється окреме поняття використання Media.

Я відокремила Media від його використання

Одне Media і кілька контекстів використання
Media  |  +-- Post / IMAGE  |  +-- News / IMAGE  |  +-- PostSeries / IMAGE  |  +-- ContentBlock / IMAGE

Media описує сам ресурс.

Usage описує факт, що конкретна сутність використовує цей ресурс у конкретному контексті.

Це різні речі, і саме це розділення робить заміну та видалення передбачуваними.

Під час вибору Media я не довіряю лише значенню з форми

Media Picker може показувати тільки зображення і не показувати видалені файли. Але це зручність інтерфейсу, а не серверна гарантія.

Запит можна змінити вручну, тому сервер повторно перевіряє Media.

PHP - Повторно перевірити Media на сервері
$media = Media::query()    ->findOrFail($mediaId); if (! $media->isImage()) {    throw ValidationException::withMessages([        'image_media_id' => 'Selected media must be an image.',    ]);}
Фільтрація Media Picker не замінює серверну перевірку. Видалене, неіснуюче або Media неправильного типу не повинно прийматися лише тому, що клієнт надіслав його ID.

Заміна зображення є зміною двох зв’язків

Коли обкладинка змінюється з Media A на Media B, недостатньо записати новий foreign key.

Для usage tracking це дві події:

Заміна Media
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.

На рівні ідеї виклик виглядає так:

PHP - Синхронізувати старе і нове використання 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 перед або в межах операції видалення.

PHP - Приклад очищення nullable Media reference
$news->image_media_id = null;$news->save();
Конкретна стратегія залежить від контексту. Для одного зв’язку правильним може бути null, для іншого видалення має бути заборонене, а для третього потрібна заміна Media. Важливо, щоб рішення було явним.

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 повинен бути визначений так само чітко, як і робота з базою.

Контрольований порядок permanent delete
1. Resolve usages2. Apply deletion policy3. Clean references4. Remove Media record5. Remove physical file at the safe point

Подія після commit корисна для похідного стану Media

Після зміни usage іншим частинам системи може знадобитися оновити похідний стан або кешовану інформацію.

Я не хочу повідомляти про успішну зміну раніше, ніж транзакція бази даних справді завершилася.

PHP - Відправити Media usage events після commit
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 використовується в кількох місцях, адмінка може показати це до остаточного видалення.

Приклад usage information
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
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. Вона починається з відповіді на питання: де використовується цей файл і що станеться з усіма цими місцями, якщо його змінити або видалити.