Skip to content

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
  • Два индекса на одних и тех же столбцах — оставить один
  • Индекс поглощается более широким составным — удалить узкий

Общие паттерны столбцов

ПаттернСтолбецПримечания
Slugstring('slug')->unique()Или composite unique с parent
Sort orderunsignedSmallInteger('sort_order')->default(0)Всегда с default
Active flagboolean('is_active')->default(true)
Self-referenceforeignId('parent_id')->nullable()->constrained('same_table')->nullOnDelete()
Composite uniqueunique(['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 без GINSeq 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

Внутренняя база знаний студии