---
title: "М'яке видалення проти унікальних індексів: дублікат SKU, який ховався рік"
url: https://laravelukraine.com/blog/miake-vidalennia-proti-unikalnix-indeksiv-dublikat-sku-iakii-xovavsia-rik
date: 2026-11-10
source: https://dev.to/developertugrul/soft-deletes-vs-unique-constraints-the-duplicate-sku-bug-that-hid-for-a-year-3m1l
---

# М'яке видалення проти унікальних індексів: дублікат SKU, який ховався рік

Якось продакт-менеджер поставив мені просте запитання: «Чому на екрані складу два різні товари з однаковим SKU?»

Я був певен, що це неможливо. Таблиця `products` мала унікальний індекс на `sku`. Форма перевіряла унікальність. У нас були тести. І все ж ось вони: два активні рядки, один SKU, різні ціни, і кожен місяцями збирав власні рухи залишків.

Помилка була не в контролері й не в запиті до бази. Вона сиділа в одному рядку міграції, який на код-рев'ю виглядав цілком розумно. Ця стаття - історія того рядка: чому він не працює і яких патернів я тепер тримаюся щоразу, коли в бізнес-застосунку на Laravel зустрічаються м'яке видалення й унікальність.

### Проблема: м'яке видалення й унікальні ключі не погоджуються

М'яке видалення (Soft Delete) в ERP- і CRM-системах майже обов'язкове. Фізично видаляти товар, клієнта чи постачальника зазвичай не хочеться, бо на них досі посилаються рахунки, замовлення та журнали аудиту. Laravel робить це простим завдяки трейту `SoftDeletes` і стовпцю `deleted_at`.

Бізнес-ключі трапляються так само часто: SKU товару, податковий номер клієнта, код постачальника, номер перепустки працівника. Вони мають бути унікальними, і очевидне місце, де це забезпечити, - база даних.

Саме тут вони й стикаються. Перша версія нашої міграції виглядала так:

```php
  Schema::create('products', function (Blueprint $table) {
      $table->id();
      $table->string('sku', 64)->unique();
      $table->string('name');
      $table->softDeletes();
      $table->timestamps();
  });
```

Це працювало, доки користувач не видалив помилково створений товар і не спробував створити його знову з правильними даними. Видалений рядок досі тримав SKU, тож вставка падала. Правило валідації було тієї самої думки, бо `Rule::unique('products', 'sku')` перевіряє таблицю, а не Eloquent-модель, тож м'яко видалені рядки теж рахуються. Як налаштувати це правило під свої умови, ми розбирали в пораді [`unique` з додатковими умовами](https://laravelukraine.com/blog/unique-z-dodatkovimi-umovami).

Прийшов тікет: «Не можу повторно використати SKU після видалення товару». Виправлення, яке пішло в продакшн, було таким:

```php
  $table->dropUnique(['sku']);
  $table->unique(['sku', 'deleted_at']);
```

Міркування звучало переконливо: у видаленого рядка в `deleted_at` стоїть часова мітка, в активного - `NULL`, отже пара `(sku, deleted_at)` має бути унікальною для активних товарів і вільною для видалених.

Це міркування хибне.

### Чому композитний індекс нічого не дає активним рядкам

У SQL `NULL` не дорівнює `NULL`. Документація MySQL каже про це прямо: індекс `UNIQUE` допускає кілька значень `NULL` у стовпцях, які можуть містити `NULL`. PostgreSQL за замовчуванням поводиться так само.

Тож для кожного активного товару індекс бачить `(sku, NULL)`, і два рядки `('ABC-100', NULL)` дублікатами не вважаються. Композитний індекс забороняє лише два *видалені* рядки з однаковим SKU і точно однаковою часовою міткою видалення - тобто єдиний випадок, який нікого не хвилює.

Чому це помітили лише через рік? Бо валідація форми все ще ловила більшість дублікатів. Справжня шкода приходила шляхами, які форму оминали:

- нічний імпорт CSV, що використовував `upsert()` з ключем за `id`, а не за `sku`;
- ендпоїнт API, який згодом додала інша команда і який перевіряв лише формат;
- двоє користувачів, що одночасно зберігали той самий новий товар (валідація пропустила обох, бо жодного рядка ще не існувало).

Валідація - це функція користувацького досвіду. Гарантію дає обмеження в базі даних. А ми цю гарантію тихо прибрали.

### Виправлення: навчіть базу розуміти, що таке «активний»

Мету сформулювати просто: **SKU має бути унікальним серед рядків, де `deleted_at IS NULL`.** Виразити це можна двома добрими способами, залежно від вашої бази даних.

#### PostgreSQL: частковий унікальний індекс

PostgreSQL підтримує часткові індекси, і це рівно те саме правило. У міграції я використовую сирий SQL-вираз, щоб намір було видно явно:

```php
  use Illuminate\Database\Migrations\Migration;
  use Illuminate\Support\Facades\DB;

  return new class extends Migration
  {
      public function up(): void
      {
          DB::statement(
              'CREATE UNIQUE INDEX products_sku_active_unique
               ON products (sku)
               WHERE deleted_at IS NULL'
          );
      }

      public function down(): void
      {
          DB::statement('DROP INDEX IF EXISTS products_sku_active_unique');
      }
  };
```

Активні рядки мусять мати унікальні SKU. Видалених рядків в індексі немає взагалі, тож видаляти й створювати SKU знову можна скільки завгодно.

#### MySQL / MariaDB: згенерований стовпець

Часткових індексів у MySQL немає, зате є згенеровані стовпці, і будівник схеми Laravel підтримує їх через `virtualAs()` і `storedAs()`. Трюк у тому, щоб створити стовпець, який для активних рядків містить SKU, а для видалених - `NULL`, і поставити на нього звичайний унікальний індекс. Оскільки кілька `NULL` дозволені, видалені рядки ніколи не конфліктують.

```php
  use Illuminate\Database\Migrations\Migration;
  use Illuminate\Database\Schema\Blueprint;
  use Illuminate\Support\Facades\Schema;

  return new class extends Migration
  {
      public function up(): void
      {
          Schema::table('products', function (Blueprint $table) {
              $table->dropUnique(['sku', 'deleted_at']);

              $table->string('active_sku', 64)
                  ->nullable()
                  ->storedAs('CASE WHEN deleted_at IS NULL THEN sku END');

              $table->unique('active_sku');
          });
      }

      public function down(): void
      {
          Schema::table('products', function (Blueprint $table) {
              $table->dropUnique(['active_sku']);
              $table->dropColumn('active_sku');
              $table->unique(['sku', 'deleted_at']);
          });
      }
  };
```

Кілька зауважень з досвіду роботи з цим у продакшні:

- Я використовую `CASE WHEN ... END` замість MySQL-функції `IF()`, щоб той самий вираз природно читався й на інших рушіях.
- `storedAs()` трохи коштує місця на диску, зате найменше дивує. InnoDB уміє індексувати й віртуальні стовпці; оберіть один варіант і тримайтеся його.
- Ніколи не пишіть у `active_sku` із застосунку. Не додавайте його в `$fillable` і вважайте доступним лише для читання.

#### Почистіть дані, перш ніж додавати обмеження

Якщо помилка вже сталася, новий унікальний індекс просто не створиться. Спершу знайдіть дублікати:

```sql
  SELECT sku, COUNT(*) AS active_rows, GROUP_CONCAT(id) AS product_ids
  FROM products
  WHERE deleted_at IS NULL
  GROUP BY sku
  HAVING COUNT(*) > 1;
```

(`GROUP_CONCAT` - це MySQL; у PostgreSQL використовуйте `string_agg(id::text, ',')`.)

Злиття дублікатів - бізнес-рішення, а не технічне. Хтось має вирішити, який рядок лишиться і куди підуть його рухи залишків, відкриті замовлення та історія цін. Оформіть це як перевірену міграцію даних, а не як швидкий фікс на продакшні.

### Лишіть валідацію, але узгодьте її з базою

Валідація все одно важлива, бо зрозуміле повідомлення краще за помилку 500. Правило `Unique` у Laravel має метод `withoutTrashed()`, який ігнорує м'яко видалені рядки, - і тепер воно точно збігається з правилом бази даних:

```php
  use Illuminate\Validation\Rule;

  public function rules(): array
  {
      $product = $this->route('product');

      return [
          'sku' => [
              'required',
              'string',
              'max:64',
              Rule::unique('products', 'sku')
                  ->withoutTrashed()
                  ->ignore($product?->id),
          ],
          'name' => ['required', 'string', 'max:255'],
      ];
  }
```

Тепер валідація й обмеження описують одне правило. Валідація дає гарне повідомлення, а обмеження зупиняє гонки, імпорти та ендпоїнти інших команд.

### Відновлення тепер стає конфліктом

Ось частина, про яку забувають. Щойно SKU можна використовувати повторно, видалений товар може мати той самий SKU, що й новіший активний. Виклик `restore()` на старому порушить обмеження. Починаючи з Laravel 10, порушення унікальності кидає `Illuminate\Database\UniqueConstraintViolationException`, тож помилку можна перетворити на зрозуміле повідомлення:

```php
  use Illuminate\Database\UniqueConstraintViolationException;
  use Illuminate\Validation\ValidationException;

  public function restore(Product $product): RedirectResponse
  {
      try {
          $product->restore();
      } catch (UniqueConstraintViolationException $e) {
          throw ValidationException::withMessages([
              'sku' => "Інший активний товар уже використовує SKU {$product->sku}. "
                  . 'Змініть один із SKU перед відновленням.',
          ]);
      }

      return back()->with('status', 'Товар відновлено.');
  }
```

Щоб прив'язка моделі до маршруту (Route Model Binding) знайшла видалений товар, маршруту потрібен `->withTrashed()`.

### Типові пастки

**Регістр і пробіли.** За нечутливого до регістру зіставлення (collation) `abc-100` і `ABC-100` конфліктують, а в PostgreSQL - ні. Вирішіть, що означає «той самий SKU», і нормалізуйте значення під час збереження (наприклад, обрізайте пробіли й переводьте у верхній регістр у мутаторі), щоб база й бізнес розуміли це однаково.

**Тести на іншому рушії.** Якщо тести ганяються на SQLite, а продакшн працює на MySQL, ваші тести обмежень доводять дуже мало. Запускайте принаймні тести схеми й цілісності даних на справжньому рушії.

**Масові записи оминають Eloquent.** `DB::table()->insert()`, `upsert()` і сирі імпорти ніколи не проходять через ваші form request. Саме тому правило має жити в базі даних.

**Інші бізнес-ключі.** SKU тут лише приклад, але та сама помилка ховається в податкових номерах клієнтів, кодах постачальників, email-адресах користувачів і номерах документів. Пошукайте у своїх міграціях `unique([` поруч із `deleted_at`.

**Зовнішні ключі на бізнес-ключ.** Якщо інші таблиці посилаються на `sku`, а не на `id`, повторне використання SKU тихо переприв'яже історію до нового товару. Посилайтеся на первинний ключ.

### Підсумок

- Унікальний індекс на `(column, deleted_at)` **не** захищає активні рядки, бо значення `NULL` ніколи не конфліктують.
- У PostgreSQL використовуйте частковий унікальний індекс `WHERE deleted_at IS NULL`.
- У MySQL індексуйте згенерований стовпець, який для видалених рядків дорівнює `NULL`.
- Використовуйте `Rule::unique(...)->withoutTrashed()`, щоб валідація збігалася з обмеженням.
- Обробляйте `UniqueConstraintViolationException` під час відновлення і прибирайте наявні дублікати до того, як додавати індекс.

Урок, який я виніс, ширший за м'яке видалення: коли виправлення закриває тікет, запитайте себе, яку гарантію воно прибрало. Наша гарантія жила в одному рядку, і ніхто не помітив, коли він змінився.

Який бізнес-ключ у вашому застосунку ви б назвали справді унікальним сьогодні - і чи перевіряли ви його на активних рядках?
