Увійти Реєстрація
Вакансії
Вакансії Laravel Компанії Статистика
Підготовка
Як підготуватися < href="https://laravelukraine.com/interviews" wire:navigate.hover class="flex items-center gap-3 rounded-md px-3 py-2 text-base font-medium text-gray-600 hover:bg-gray-50 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-900 dark:hover:text-white" >Співбесіди Тестування Документація
Блог
Всі статті Серії
Екосистема
Пакети Ресурси Проєкти Інструменти Події Відео
Інше
Про нас Реклама
Переклади 10 листопада 2026

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

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

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

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

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

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

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

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

  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 з додатковими умовами.

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

  $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-вираз, щоб намір було видно явно:

  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 дозволені, видалені рядки ніколи не конфліктують.

  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 і вважайте доступним лише для читання.

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

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

  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(), який ігнорує м'яко видалені рядки, - і тепер воно точно збігається з правилом бази даних:

  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, тож помилку можна перетворити на зрозуміле повідомлення:

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

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

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

Переклад оригіналу: dev.to
15

Читати в документації

Коментарі

Увійдіть, щоб залишити коментар

Будьте першим, хто залишить коментар!

Читайте також

Міграції без простою
Переклади 11 листопада 2026

Міграції бази даних без простою в Laravel: expand-contract і lock_timeout

Чому невинна на вигляд міграція кладе продакшн і як цього уникнути: патерн expand-contract, lock_timeout на кожну DDL-операцію, індекси без блокування записів, пакетне заповнення в завданнях черги і правильний порядок розгортання для PostgreSQL та MySQL.

8
View Transitions
Переклади 10 листопада 2026

CSS View Transitions у Laravel і Livewire: анімації переходів без SPA

Як одне CSS-правило перетворює звичайні переходи між сторінками Laravel і Livewire на плавні анімації, як змусити плитку гри «вирости» в ігрове поле і чому горизонтальне переповнення у два пікселі мовчки вимикало все це на телефонах.

10

Вакансії за темою

Black River Express
10 днів тому

Senior PHP Developer

Senior PHP розробник для логістичної платформи: бекенд-сервіси на PHP 8.3+ та Laravel 11 і веб-панель на Filament 3 та Livewire 3. Завдання: REST API, авторизація, інтеграції через API і вебхуки, оптимізація PostgreSQL, черги та кешування. Вимоги: комерційний досвід Laravel, знання PostgreSQL, Redis, WebSockets, CI/CD та діагностики продакшн-проблем. Віддалено, також розглядають сильних Middle+.

ВебФорза Нова
Сьогодні

Програміст PHP (Laravel, WordPress, гіг-контракт)

Розробка на фреймворку Laravel та створення тем і плагінів для WordPress. Завдання включає роботу з jQuery, JS, CSS, HTML і PHP, використання git та оптимізацію швидкості завантаження сторінок. Оформлення за гіг-контрактом з погодинною оплатою.

Full Stack Developer (PHP / React) Middle / Middle+

Full Stack розробник розвиватиме та підтримуватиме власний аналітичний продукт для перевірки контрагентів і оцінки ризиків, працюючи зі складною бізнес-логікою та великими обсягами даних. Основний стек: PHP 8.x, Laravel або Symfony, MySQL, React; також потрібні проєктування API, оптимізація SQL-запитів, Docker, CI/CD. Вимоги: комерційний досвід від 3 років, досвід підтримки legacy-коду, англійська B1+ для читання документації.

Пакети за темою

Kalimera

welldigit/kalimera

Interactive installer that scaffolds Laravel applications the Well Digit way.

v1.0.0 17

Bagisto

bagisto/bagisto

Bagisto — це платформа для електронної комерції, побудована на Laravel. Вона надає готове рішення для створення та управління інтернет-магазинами з підтримкою каталогу товарів, замовлень, платежів та клієнтів.

28,359 v2.5.1 13 28