Зовнішній ключ база перевіряє в тій самій транзакції, що й сам запис: INSERT у applications із неіснуючим vacancy_id не пройде (MySQL: помилка 1452, PostgreSQL: SQLSTATE 23503), а DELETE батька або впаде з 1451, або виконає описану дію. Перевірка в коді на кшталт «спочатку exists, потім insert» дає таку саму гарантію лише на папері: між двома запитами батька встигнуть видалити. Плюс до застосунку пишуть ще й воркери, консольні команди, SQL-міграції, а є ще руки адміна в psql, і жодна з цих доріг не проходить через вашу модель. Осиротілі рядки не викидають помилок і не падають у Sentry, вони просто тихо ламають звіти й JOIN-и через півроку.
Дій усього кілька, і кожна оголошується окремо для ON DELETE та ON UPDATE. Без явного клаузу діє NO ACTION, що на практиці означає заборону: у MySQL це те саме, що RESTRICT, а в PostgreSQL різниця в тому, що NO ACTION можна відкласти до кінця транзакції через DEFERRABLE, тоді як RESTRICT перевіряється одразу й відкладенню не підлягає. CASCADE підходить рядкам, які не мають життя без батька: позиції замовлення, токени сесій, відгуки на видалену вакансію. RESTRICT ставлять там, де видалення батька майже завжди означає помилку оператора: компанія з активними вакансіями, користувач з оплаченими замовленнями. SET NULL беруть для звʼязку, який справді опційний, і колонка мусить бути nullable (MySQL відмовиться створити таке обмеження на NOT NULL, PostgreSQL створить і впаде на першому видаленні). SET DEFAULT в InnoDB не працює взагалі: парсер його приймає, а рушій відхиляє таблицю. У PostgreSQL 15 зʼявилася форма ON DELETE SET NULL (column), щоб у складеному ключі занулювати лише частину колонок.
ON UPDATE турбує тільки тоді, коли значення ключа може змінитися. З сурогатним bigserial чи UUID первинний ключ не оновлюють ніколи, тому ON UPDATE CASCADE там декорація. А от для природних ключів (код країни, скорочення валюти, slug у ролі PK) це єдиний спосіб не розсипати посилання при перейменуванні, і саме це аргумент проти природних ключів у схемах, де такі перейменування трапляються.
Тепер про індекс. PostgreSQL індексує лише сторону, на яку посилаються, а колонку company_id у vacancies залишає неіндексованою, поки ви не створите індекс самі. На маленьких таблицях це непомітно; на сотнях тисяч рядків кожне видалення батька починає сканувати дочірню таблицю, тримаючи при цьому блокування, і питання «чому DELETE триває сорок секунд» має саме цю відповідь. InnoDB від цієї проблеми захищений, бо створює потрібний індекс автоматично. Глибокі ланцюжки каскадів шкодять інакше: одна команда може видалити піддерево на мільйон рядків в одній транзакції, і замість швидкого DELETE ви отримуєте довгі блокування та відставання реплік. Великі чистки роблять пачками знизу вгору.
Найчастіша пастка в Laravel-проєктах - трейт SoftDeletes поруч із cascadeOnDelete() у міграції. Soft delete виконує UPDATE ... SET deleted_at = now(), рядок лишається в таблиці, отже база не бачить події, на яку реагує FK: каскад не спрацює, nullOnDelete нічого не занулить, а restrictOnDelete нікому не завадить. Дочірні записи й далі посилаються на «видаленого» батька, і фільтрувати їх доводиться в кожному запиті. Так само FK не заборонить вставити дитину до soft-deleted батька: для бази той рядок живий. Вибирайте одне: або справжнє видалення з каскадом у схемі, або soft delete на всьому піддереві з ручним каскадом в обробниках deleting/restoring у межах транзакції. Ще одна деталь, яку легко проґавити при аудиті: у MySQL каскадні дії FK не запускають тригери, а в Laravel каскадне видалення на рівні бази не викликає події моделей, тому прибирання файлів, кешу чи індексу пошуку треба робити явно, до видалення батька.
CREATE TABLE companies (
id bigserial PRIMARY KEY,
name text NOT NULL
);
CREATE TABLE vacancies (
id bigserial PRIMARY KEY,
-- компанію з вакансіями видаляти не можна: спершу розберись із вакансіями
company_id bigint NOT NULL REFERENCES companies (id) ON DELETE RESTRICT,
-- рекрутер може піти, вакансія лишається без відповідального
recruiter_id bigint REFERENCES users (id) ON DELETE SET NULL,
title text NOT NULL
);
CREATE TABLE applications (
id bigserial PRIMARY KEY,
-- відгук не існує без вакансії: чистимо разом із нею
vacancy_id bigint NOT NULL REFERENCES vacancies (id) ON DELETE CASCADE,
candidate_id bigint NOT NULL REFERENCES candidates (id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now()
);
-- PostgreSQL не індексує дочірню колонку сам: без цих індексів
-- DELETE у батьку робить Seq Scan по дитині. InnoDB створює їх автоматично.
CREATE INDEX vacancies_company_id_idx ON vacancies (company_id);
CREATE INDEX applications_vacancy_id_idx ON applications (vacancy_id);
CREATE INDEX applications_candidate_id_idx ON applications (candidate_id);
-- Впаде з 23503 foreign_key_violation, поки є хоч одна вакансія
DELETE FROM companies WHERE id = 42;
-- Тому спершу дивимось, що саме заблокує видалення
SELECT count(*) FROM vacancies WHERE company_id = 42;
Назвіть критерій одним реченням: чи має цей рядок сенс без батька? Немає - CASCADE, має і важливий - RESTRICT, має і звʼязок опційний - SET NULL. І окремо додайте, що під FK у PostgreSQL індекс створюєте руками.