Тема
Migrations & Seeders
Миграции
Именование
- Формат:
YYYY_MM_DD_HHMMSS_action_table.php(Laravel default) или000XXX_action_table.php(проектная конвенция) - Действие:
create_,add_column_to_,modify_column_in_,drop_ - Таблица: plural snake_case
Foreign Keys
- ВСЕГДА
->constrained()— автоматическое определение таблицы/столбца - ВСЕГДА
->index()на FK столбцах ->cascadeOnDelete()— для зависимых данных (items заказа)->nullOnDelete()— для опциональных связей (автор удалён, но запись остаётся)->restrictOnDelete()— для защиты от удаления (нельзя удалить категорию с товарами)- Осознанный выбор стратегии — не "по умолчанию"
JSON столбцы
->json('column')->nullable()— всегда nullable, т.к. JSON не имеет default в MySQL- В модели — cast
'array'обязателен - Не хранить в JSON то, по чему нужен поиск/фильтрация — вынести в отдельный столбец
Enum столбцы
$table->enum('status', ['draft', 'active', 'archived'])— не string- В модели — PHP Enum class для type-safety
- При добавлении нового значения — отдельная миграция
modify
Индексы
Индексировать:
- Все FK столбцы (автоматически при
constrained, но проверять) - Столбцы в WHERE:
status,type,is_active,slug - Столбцы в ORDER BY:
sort_order,created_at - Composite для частых запросов:
index(['page_type', 'position', 'is_active']) - UNIQUE на slug, email, и бизнес-уникальные поля
- FULLTEXT для полнотекстового поиска:
fullText(['name', 'content'])
Не индексировать:
- Столбцы с низкой кардинальностью в маленьких таблицах (< 1000 записей)
- Столбцы, которые только читаются, не фильтруются
- Поля ETL/импорта (confidence_score, state_status, is_noise_filtered, coordinates_source) — если не используются в публичных запросах
Специальные типы индексов (PostgreSQL)
Partial index — когда фильтрация всегда по одному значению:
php
DB::statement("CREATE INDEX CONCURRENTLY idx_ot_ru ON organization_translations (organization_id)
INCLUDE (name, description) WHERE locale = 'ru'");Экономит 60–80% размера vs полного индекса.
Functional index — для lower(), JSON-полей, вычисляемых выражений:
php
// ILIKE с кириллицей НЕ работает с обычным btree — нужен lower()
DB::statement("CREATE INDEX CONCURRENTLY idx_orgs_city_street_lower
ON organizations (city_id, (lower(address_structured ->> 'street')))
WHERE (address_structured ->> 'street') IS NOT NULL");
// Запрос: lower(address_structured->>'street') LIKE lower(?)GIN на tsvector — для полнотекстового поиска (PostgreSQL):
php
// 1. Добавить STORED generated column
DB::statement("ALTER TABLE organization_translations
ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
to_tsvector('russian', COALESCE(name,'') || ' ' || COALESCE(description,''))
) STORED");
// 2. GIN индекс (partial по locale)
DB::statement("CREATE INDEX CONCURRENTLY idx_ot_search_vector_gin_ru
ON organization_translations USING gin (search_vector) WHERE locale = 'ru'");
// Запрос: search_vector @@ plainto_tsquery('russian', ?)Без GIN — Seq Scan 9M+ строк (~127s). С GIN — ~150ms.
Covering index (INCLUDE) — избегает heap fetch при Index Only Scan:
php
DB::statement("CREATE INDEX CONCURRENTLY idx_ot_ru_covering
ON organization_translations (organization_id)
INCLUDE (name, description) WHERE locale = 'ru'");Правила для миграций с индексами на больших таблицах
CREATE INDEX CONCURRENTLY— обязательно для таблиц > 1M строк (не блокирует)$withinTransaction = falseв классе миграции при CONCURRENTLY (иначе PDO exception)DROP INDEX CONCURRENTLY— для удаления без блокировкиADD COLUMN ... GENERATED ALWAYS AS (...) STOREDна 20M строк ≈ 10–15 минут — нормально- После массового ETL-импорта — запустить
ANALYZE table_name(статистика планировщика устаревает)
Аудит индексов (периодически)
bash
./dc.sh art db:index-audit # неиспользуемые индексы, slow queries, seq_scan
./dc.sh art db:slow-log # топ медленных запросов из pg_stat_statements
./dc.sh art db:health-check # bloat, устаревшая статистикаПризнаки проблемного индекса:
idx_scan = 0при размере > 100 MB — кандидат на DROP- Два индекса на одних и тех же столбцах — оставить один
- Индекс поглощается более широким составным — удалить узкий
Общие паттерны столбцов
| Паттерн | Столбец | Примечания |
|---|---|---|
| Slug | string('slug')->unique() | Или composite unique с parent |
| Sort order | unsignedSmallInteger('sort_order')->default(0) | Всегда с default |
| Active flag | boolean('is_active')->default(true) | — |
| Self-reference | foreignId('parent_id')->nullable()->constrained('same_table')->nullOnDelete() | — |
| Composite unique | unique(['city_id', 'slug']) | Когда slug уникален в рамках parent |
Pivot-таблицы
- Без
idстолбца, без timestamps - Composite primary key:
primary(['a_id', 'b_id']) - Имя таблицы: алфавитный порядок моделей, singular (
brand_category) - FK с
cascadeOnDelete— удаление parent удаляет связь
down() метод
- ВСЕГДА определять — для возможности отката
dropIfExistsдля create-миграцийdropColumnдля add-миграций
Seeders
Принципы
- Один seeder на модель/группу связанных моделей
- Seed в порядке зависимостей: parents → children
DatabaseSeeder— только оркестрация порядка, без данных- Idempotency:
firstOrCreateилиupdateOrCreateкогда seeder может запускаться повторно
Источники данных
Seeders импортируют из внешних источников:
- JSON файлы
- SQLite базы
- PHP массивы
- CSV файлы
- Внешние API
НЕ хардкодить данные в seeder — только чтение из источника.
Связанные данные
При импорте данных со связями — создавать parents первыми, затем children с привязкой по slug/name (не по id, т.к. id может отличаться).
Anti-patterns
| Паттерн | Проблема | Решение |
|---|---|---|
| FK без index | Медленные JOIN на больших таблицах | ->constrained() + ->index() |
| JSON для фильтруемых данных | Невозможно индексировать | Отдельный столбец |
| string вместо enum | Нет валидации на уровне БД | $table->enum() |
Отсутствие down() | Невозможно откатить миграцию | Всегда определять |
| Хардкод данных в seeder | Рассинхрон с реальными данными | Импорт из источника |
cascadeOnDelete на всё | Каскадное удаление неожиданных данных | Осознанный выбор стратегии |
| Один seeder на всё | Невозможно seed частично | По модели/группе |
ILIKE '%кириллица%' без lower() индекса | btree индекс не используется с кириллицей | Functional index на lower(col) + запрос с lower() |
to_tsvector(...) в WHERE без GIN | Seq Scan всей таблицы (~127s на 9M строк) | STORED generated column + GIN partial index |
| Накопление неиспользуемых индексов | +10 GB диска, замедление INSERT/UPDATE на 15% | Регулярный db:index-audit, DROP CONCURRENTLY |
CREATE INDEX без CONCURRENTLY на > 1M строк | Блокировка таблицы на минуты | Всегда CONCURRENTLY + $withinTransaction = false |