Якось продакт-менеджер поставив мені просте запитання: «Чому на екрані складу два різні товари з однаковим 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 під час відновлення і прибирайте наявні дублікати до того, як додавати індекс.
Урок, який я виніс, ширший за м'яке видалення: коли виправлення закриває тікет, запитайте себе, яку гарантію воно прибрало. Наша гарантія жила в одному рядку, і ніхто не помітив, коли він змінився.
Який бізнес-ключ у вашому застосунку ви б назвали справді унікальним сьогодні - і чи перевіряли ви його на активних рядках?