Phinx: миграции базы данных в PHP — мастерская и 8 опытов
Обновляем структуру и данные базы вместе с PHP-проектом: восемь настоящих опытов с Phinx, сравнение до и после, ошибки, rollback, seeds и проверяемый порядок релиза. Мастерская и исходники для Windows и Linux.

Миграции базы данных в PHP без фреймворка: восемь настоящих опытов, схема и данные до/после, ошибки, rollback, seeds и практический порядок обновления на Windows, Linux и VPS.
Phinx: как обновлять базу вместе с приложением
В CRM появился телефон клиента, в каталоге нужен новый индекс, а в сервисе заявок пора запретить повторяющиеся внешние идентификаторы. Изменить таблицу в своей базе легко. Сложнее добиться, чтобы такое же изменение произошло у коллеги, на тестовом сервере и в рабочем приложении, сохранило данные и оставило понятный след. Phinx помогает превратить изменение базы в версионируемую часть проекта.
Вместо сообщения «выполни этот SQL перед обновлением» вы добавляете PHP-файл миграции в репозиторий. Команда читает каталог, сравнивает версии с журналом конкретной базы и выполняет недостающие изменения. В результате релиз включает не только новый PHP-код, но и воспроизводимый переход от прежней структуры к новой. После этого можно проверить статус и фактическую схему, а не вспоминать, кто последним открывал административную панель базы.
Ниже разберём восемь опытов: создание таблицы, добавление поля, перенос данных, индекс, конфликт уникальности, необратимое изменение, ошибку внутри транзакции и повторяемый seed. Вы получите мастерскую с настоящими результатами PHP CLI, скачиваемый проект и порядок применения на Windows, Linux и VPS.
Где миграции приносят практическую пользу
Phinx особенно удобен в PHP-проекте без большого фреймворка, где уже есть приложение, Composer и база, но нет собственного процесса обновления схемы. Это может быть личный кабинет, небольшой магазин, интеграционный сервис, административная панель или внутренний инструмент компании. Библиотеке не требуется переводить весь проект на другой способ маршрутизации или менять его шаблоны.
Возьмём сервис заявок. Сегодня у заявки есть тема и текст; завтра появляется приоритет, затем ответственный, затем журнал событий. Каждый шаг затрагивает интерфейс, серверную проверку и хранение данных. Миграция фиксирует только последний слой: какую колонку, таблицу или индекс следует добавить. Код приложения продолжает заниматься обработкой запросов. Такое разделение позволяет обсуждать изменение схемы на ревью вместе с функцией, ради которой оно появилось.
Для команды это ещё и способ воспроизведения проблемы. Можно поднять пустую тестовую базу, применить конкретный набор миграций, загрузить демонстрационные данные и получить известное исходное состояние. Сама библиотека не выбирает архитектуру данных за разработчика: смысл полей, ограничения и допустимый простой по-прежнему нужно определить.
Миграция, SQL, ORM и резервная копия: у каждого своя задача
SQL описывает действия над базой. Phinx организует порядок и историю этих действий, а его Table API позволяет выразить часть изменений через PHP. ORM, если он есть, связывает строки с объектами приложения. Эти инструменты могут работать вместе: модель клиента читает данные, а миграция добавляет поле, которое модель начнёт использовать после обновления.
Резервная копия хранит состояние базы на определённый момент. Файл миграции такого состояния не содержит. Если удалить колонку с примечаниями, обратное добавление пустой колонки не восстановит тексты. Поэтому слово rollback следует читать буквально: выполнить предусмотренный обратный переход. Его полнота зависит от того, что вы сохранили и как написали обратный код.
В практической работе полезно задавать два отдельных вопроса. Первый: можно ли вернуть прежнюю структуру? Второй: можно ли вернуть прежние данные, включая изменения пользователей после релиза? У второго вопроса часто другой ответ. В мастерской есть намеренно необратимый опыт именно для того, чтобы эта разница была видна в строках базы, а не оставалась абстрактным предупреждением.
Мини-мастерская: сначала попробуйте, затем повторите у себя
Выберите задачу и нажмите «Начать опыт». Каждый следующий шаг показывает реальную команду, код её завершения и два снимка SQLite: непосредственно до команды и после неё. Вкладки позволяют переключиться между строками, SQL-схемой, планом запроса и журналом версий. Для индекса подготовлена тысяча дополнительных клиентов; таблица показывает первые шесть строк и полное количество записей.
Мастерская воспроизводит сохранённый запуск PHP, а не выполняет миграции на серверной базе сайта по нажатию посетителя. Это позволяет безопасно изучать в том числе ошибки и удаление колонок. Рецепт для Windows или Linux собирается из выбранного опыта; его можно скопировать и выполнить в скачанном проекте. Новый результат вы получите своим запуском composer capture.
Кнопка SQL сохраняет снимок структуры после выбранного шага. В нём нет содержимого таблиц и последовательности преобразований, поэтому такой файл не является резервной копией или заменой PHP-миграции. JSON содержит больше деталей: команды, исходное состояние, результаты и хеши файлов. Код опыта доступен прямо в интерфейсе и отдельно в архиве.
Версия, требования и устройство скачанного проекта
В материале используется Phinx 0.16.12. Сам пакет требует 64-битный PHP не ниже 8.1; наш проект и его зафиксированные зависимости рассчитаны на PHP 8.2 и новее. Для SQLite нужен драйвер PDO SQLite. Перед установкой стоит проверить именно PHP командной строки: интерпретатор веб-сервера и команда php в терминале иногда читают разные конфигурационные файлы.
В архиве находятся composer.json и composer.lock, миграции, seed, скрипты записи и проверки, готовая мастерская, лицензии и инструкция README. Папка var предназначена для учебных баз. У каждого опыта свой файл и отдельный каталог миграций: намеренная ошибка уникальности не мешает изучать остальные сценарии. Это устройство лаборатории, а не рекомендация дробить обычное приложение на восемь независимых историй.
Версии зависимостей устанавливаются из lock-файла. Используйте install при повторении проекта; update нужен, когда вы осознанно обновляете зависимости и затем повторно проверяете результат. Минимальные требования и выпуск Phinx сверены с официальной карточкой пакета.
php -v
php --ini
php -r "echo PHP_INT_SIZE, PHP_EOL; print_r(PDO::getAvailableDrivers());"
composer install --no-interaction --prefer-dist
composer check-platform-reqs
Первый запуск в Windows: настоящая база и локальная страница
Распакуйте проект в собственную папку и откройте в ней PowerShell. Сначала установите зависимости и выполните capture: команда создаст тестовые базы, запустит Phinx отдельными процессами и сохранит результаты. Затем test проверит не только текст отчёта, но и фактическое содержимое файлов SQLite. После этого встроенный сервер PHP покажет готовую мастерскую по локальному адресу.
Сервер привязан к 127.0.0.1, а его корнем является только public. Конфигурация, базы и зависимости находятся снаружи. Для завершения нажмите Ctrl+C в том же терминале. Если порт занят, замените 8503 на свободный и откройте соответствующий адрес. Доступ из интернета этому учебному серверу не требуется.
При ошибке «could not find driver» проверьте PDO SQLite через команду из предыдущего раздела и путь к php.ini. Не скрывайте проблему флагом игнорирования требований Composer: установка пакета не создаёт отсутствующий драйвер. Если Composer вызывается другим PHP, сначала выровняйте окружение; иначе установка и запуск будут проверять разные платформы.
composer install --no-interaction --prefer-dist
composer capture
composer test
php -S 127.0.0.1:8503 -t public
# Открыть http://127.0.0.1:8503/
# Остановить сервер: Ctrl+C
Linux: тот же проект без зависимости от Windows
На Ubuntu 24.04 LTS можно установить PHP CLI и необходимые расширения из пакетов системы. Скачанный архив распаковывается в отдельный каталог пользователя; Composer следует запускать от обычного пользователя, который владеет этим каталогом. Исходники и lock-файл остаются теми же, что в Windows. Результат capture сам укажет Linux и фактическую версию SQLite вашей сборки PHP.
В других дистрибутивах имена пакетов и версии отличаются, поэтому после установки всё равно проверьте php -v и composer check-platform-reqs. Для готовой мастерской Node.js не требуется. Он нужен только при изменении клиентского интерфейса, пересборке локального Bootstrap и запуске DOM-тестов; команды для этого приведены в README.
Разница между PowerShell и Bash в дальнейших примерах в основном касается задания переменных окружения. Команда Phinx одинакова. Это удобно для команды, которая разрабатывает на Windows, а проверяет релизы и работает с базой на Linux: важнее одинаковые версии, настройки подключения и набор миграций, чем внешний вид терминала.
sudo apt update
sudo apt install php-cli php-sqlite3 php-mbstring php-intl php-xml php-curl php-zip unzip composer
cd phinx-project
composer install --no-interaction --prefer-dist
composer check-platform-reqs
composer capture
composer test
php -S 127.0.0.1:8503 -t public
Настройка Phinx и явный выбор окружения
Конфигурация связывает каталог миграций с конкретной базой. В нашем стенде переменная PHINX_SCENARIO выбирает один из восьми разрешённых опытов, а параметр -e — development или testing. Путь к SQLite строится внутри проекта. Неизвестные имена отклоняются; произвольный путь из формы или параметра страницы здесь не принимается.
Перед первым ручным опытом команда reset создаёт его исходные данные. Она нужна именно лаборатории: после этого можно повторять одни и те же переходы сколько угодно. В реальном приложении не должно быть автоматического сброса базы перед migrate. Там состояние сохраняется между релизами, а новые миграции продолжают уже существующую историю.
Привычка явно писать -c и -e полезна и в небольшом проекте. Так команда показывает, какой конфигурацией и каким окружением вы намерены пользоваться. Однако имя production само по себе ничего не защищает: окончательным назначением остаются параметры подключения и права процесса. Подробности формата описаны в документации конфигурации Phinx.
# PowerShell
php bin/reset.php expand development
$env:PHINX_SCENARIO = 'expand'
php vendor/bin/phinx status -c phinx.php -e development
# Bash: эквивалент выбора опыта
export PHINX_SCENARIO=expand
php vendor/bin/phinx status -c phinx.php -e development
Как читать migrate, status и журнал phinxlog
Migrate применяет недостающие версии; повторный запуск при неизменном наборе файлов не повторяет уже отмеченные миграции. Status показывает соотношение каталога и истории выбранной базы. Внутри SQLite эта история хранится в phinxlog. В мастерской можно увидеть её отдельно от пользовательских таблиц и сравнить состояние после успешной команды и после ошибки.
Журнал отвечает на вопрос «какие версии Phinx считает применёнными». Он не доказывает, что никто позже не изменил таблицу вручную. Поэтому для приёмки полезно смотреть и на статус, и на реальную схему, и на контрольные запросы. В наших тестах после каждого опыта сверяются столбцы, строки, индексы и ожидаемый результат операции.
Обратите внимание на коды завершения. В опытах с намеренной ошибкой migrate возвращает 1, а status после этого — 3, сообщая о неприменённой миграции. Автоматизация должна различать такой статус и успешное состояние, а не ориентироваться только на наличие текста в консоли. Для рабочего релиза неприменённая миграция требует решения, даже если приложение пока ещё отвечает на запросы.
php vendor/bin/phinx migrate -c phinx.php -e development
php vendor/bin/phinx status -c phinx.php -e development
php bin/inspect.php expand development
Опыт 1. Создание клиентов и повторный запуск
Первый опыт начинается с пустой SQLite-базы. Миграция создаёт таблицу clients с именем, email и статусом клиента. После выполнения в мастерской появляется SQL создания таблицы и одна применённая версия. Следующий migrate показывает важное свойство процесса: уже выполненный файл не создаёт вторую таблицу и не добавляет вторую запись истории.
Так выглядит старт нового сервиса, у которого пока нет данных. Создание структуры отделено от наполнения: после миграции таблица существует, но остаётся пустой. Приложение сможет добавлять клиентов обычными запросами, а тестовые записи можно подготовить отдельным seed. Не следует помещать реальные клиентские данные или пароли в общий файл первоначальной схемы.
В реальном репозитории имя файла содержит версию и понятное название изменения. После публикации миграцию сохраняют неизменной: исправление оформляют следующим файлом. Иначе новая установка выполнит уже изменённый PHP, а существующая база пропустит прежнюю версию как выполненную. Два окружения получат разную структуру при внешне одинаковом статусе.
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class CreateClients extends AbstractMigration
{
public function change(): void
{
$this->table('clients')
->addColumn('name', 'string', ['limit' => 120])
->addColumn('email', 'string', ['limit' => 180])
->addColumn('status', 'string', ['default' => 'new', 'limit' => 20])
->create();
}
}
Опыт 2. Добавление поля без выдуманных значений
Второй опыт отвечает на частую задачу: добавить телефон существующим клиентам. У части людей номер пока неизвестен. Nullable-колонка выражает это честно: прежние строки получают NULL, а приложение может постепенно заполнить поле. Пустая строка, нулевой номер и «не указан» внутри самого телефона создают лишние условности для поиска и проверки.
После migrate откройте вкладку «Строки»: у Анны появится contact_phone со значением null. Затем посмотрите SQL и выполните следующий шаг rollback. Колонка исчезнет, а прежние имя, email и статус останутся на месте. Этот опыт подтверждает обратимость конкретного структурного изменения, когда в новом поле ещё нет ценных данных.
Если пользователи уже успели записать телефоны, такой rollback их удалит. Поэтому перед выпуском следует решить, нужен ли обратный переход и как сохранить новые сведения. Часто лучше оставить добавленную совместимую колонку и вернуть предыдущую версию кода, чем удалять её вслед за кодом автоматически.
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class AddContactPhone extends AbstractMigration
{
public function change(): void
{
$this->table('clients')->addColumn('contact_phone', 'string', [
'limit' => 40, 'null' => true, 'default' => null,
])->update();
}
}
Опыт 3. Перенос данных через явные up и down
Для поиска нужен нормализованный email, но исходное написание желательно сохранить. В третьем опыте up добавляет отдельную колонку email_normalized и заполняет её вычисленным значением. Down удаляет только эту производную копию. Поэтому после обратного перехода исходное поле содержит те же пробелы и буквы, что до миграции: их никто не перезаписывал.
Это небольшой пример backfill — заполнения нового представления данными из существующего. В рабочем приложении правило нормализации должно быть согласовано с продуктом. Здесь используются вымышленные ASCII-адреса, а lower(trim()) служит понятным учебным преобразованием. Нельзя автоматически переносить его на все международные адреса или считать изменение регистра локальной части email универсально допустимым.
Для трёх строк достаточно одного UPDATE. Для миллионов пользователей нужно оценить длительность, блокировки и влияние на текущие запросы. Сам алгоритм переноса можно вынести в отдельную возобновляемую команду, а миграцией подготовить схему. Тогда прогресс, размер порции и повтор после сбоя становятся управляемыми, вместо одного долгого действия без наблюдаемого состояния.
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class BackfillEmailCopy extends AbstractMigration
{
public function up(): void
{
$this->table('clients')->addColumn('email_normalized', 'string', ['null' => true])->update();
// Demo ASCII addresses only: SQL lower() is not a universal Unicode/email policy.
$this->execute('UPDATE clients SET email_normalized = lower(trim(email))');
}
public function down(): void
{
// Original email remains intact; the derived copy is disposable in this exercise.
$this->table('clients')->removeColumn('email_normalized')->update();
}
}
Опыт 4. Индекс: посмотреть пользу в плане запроса
Индекс нужен под конкретный запрос, а не для украшения схемы. В четвёртом опыте подготовлены 1003 клиента; приложение ищет запись по точному email. До миграции SQLite показывает SCAN. После добавления индекса план содержит SEARCH и имя idx_clients_email. После rollback снова виден просмотр таблицы. Сравнение построено на настоящем EXPLAIN QUERY PLAN из выбранной базы.
Здесь нет обещания определённого ускорения в миллисекундах. Маленькая база, прогретый кеш и особенности компьютера легко искажают такой замер. Полезный результат опыта иной: вы связываете форму запроса, присутствие индекса и способ доступа к строкам. Затем уже проверяете реальные данные и нагрузку собственного сервиса.
Индекс занимает место и должен обновляться при изменении записей. Слишком много индексов увеличивают стоимость записи. Для комбинированных условий важны порядок полей и реальные фильтры. В нашем PHP-мануале есть отдельная глава «PDO_SQLITE: резерв товара, снимок читателя и план выборки» с продолжением практики SQLite и работы нескольких соединений.
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class AddEmailIndex extends AbstractMigration
{
public function change(): void
{
$this->table('clients')->addIndex(['email'], ['name' => 'idx_clients_email'])->update();
}
}
Опыт 5. Unique и данные, которые мешают изменению
Пятое упражнение намеренно заканчивается отказом. В базе есть два клиента с одним email, а миграция пытается добавить уникальный индекс. SQLite отклоняет создание ограничения. Сравнение показывает: четыре клиента сохранились, а версия не стала применённой. Это полезный сценарий для импорта, где приложение долго принимало дубликаты, а новый релиз решил запретить их.
Правильный следующий шаг — исследовать конфликт и согласовать правило объединения либо исправления. Удаление «лишней» строки по минимальному идентификатору может уничтожить заявки, счета или историю общения. Само наличие одинакового email ещё не означает, что записи можно безусловно объединить. Такое решение относится к данным и бизнес-правилам, а не к синтаксису CREATE INDEX.
До релиза выполните диагностический запрос на копии актуальной базы. Если правило подтверждено, подготовьте отдельный проверяемый перенос связанных записей, после него снова проверьте дубликаты и только затем вводите ограничение. Добавление unique становится последним закрепляющим шагом, а не способом обнаружить плохие данные впервые на рабочем сервере.
SELECT email, COUNT(*) AS copies
FROM clients
GROUP BY email
HAVING COUNT(*) > 1;
-- В учебном опыте migrate ожидаемо завершится кодом 1:
-- UNIQUE constraint failed: clients.email
Опыт 6. Почему потерянный регистр не вернётся через rollback
Шестой опыт делает похожую нормализацию, но прямо в исходном поле email. После up пробелы и прежний регистр исчезают. Теперь обратная функция не может узнать, какие символы были раньше: множество разных исходных значений превращаются в одно. Поэтому down выбрасывает IrreversibleMigrationException с объяснением, а не делает вид, что преобразование отменено.
В мастерской обратный шаг завершается кодом 1. Версия остаётся применённой, а строка остаётся изменённой. Это честный результат: база соответствует уже выполненному преобразованию. Скрывать исключение и вручную удалять строку phinxlog нельзя — тогда журнал начнёт описывать состояние, которого в действительности нет.
Для рабочего изменения можно заранее сохранить оригиналы в отдельной колонке или таблице, разработать проверяемое обратное преобразование либо принять отсутствие rollback и подготовить исправление вперёд. Резервная копия тоже помогает, но её восстановление затронет весь выбранный снимок и потребует решить судьбу новых операций пользователей. Наличие метода down не отменяет этих вопросов.
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class NormalizeOriginalEmail extends AbstractMigration
{
public function up(): void
{
$this->execute('UPDATE clients SET email = lower(trim(email))');
}
public function down(): void
{
throw new \Phinx\Migration\IrreversibleMigrationException(
'Original spelling was overwritten. Use a reviewed forward fix or verified backup.'
);
}
}
Опыт 7. Ошибка после UPDATE и граница транзакции
Седьмая миграция сначала меняет статусы всех клиентов, затем пытается вставить запись с уже существующим первичным ключом. Это позволяет проверить более содержательную ситуацию, чем ошибка первого оператора. Если бы первое действие осталось, база оказалась бы частично обновлённой. В нашем запуске Phinx с SQLite исходные статусы сохранились: неуспешная транзакция не оставила UPDATE в файле базы.
Вкладка «Строки» показывает значения до и после, а «Версии» подтверждает отсутствие применённой миграции. В тестах сравнивается весь небольшой набор строк. Таким образом, вывод об откате основан на повторном чтении данных, а не только на сообщении исключения. Это хороший образец проверки собственных критичных миграций.
Не переносите результат автоматически на любой адаптер и любую операцию. Поведение DDL, ограничений и транзакций зависит от СУБД и способа изменения таблицы. Кроме того, транзакция базы не отменит отправленное письмо, HTTP-запрос или запись в стороннюю систему. В миграции лучше избегать таких внешних побочных действий; для них нужен отдельный процесс с собственной идемпотентностью.
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class FailAfterUpdate extends AbstractMigration
{
public function up(): void
{
$this->execute("UPDATE clients SET status = 'changed'");
// id=1 already exists. The preceding UPDATE must roll back on SQLite.
$this->execute("INSERT INTO clients (id,name,email,status) VALUES (1,'Conflict','conflict@example.test','new')");
}
public function down(): void
{
throw new \Phinx\Migration\IrreversibleMigrationException('This deliberately failing migration must never be applied.');
}
}
Опыт 8. Seed, который можно выполнить дважды
Миграция восьмого опыта создаёт справочник тегов, а DemoTags заполняет его тремя учебными значениями. Затем seed запускается ещё раз. Число строк остаётся равным трём, потому что code имеет уникальный индекс, а вставка использует SQLite ON CONFLICT DO NOTHING. Повторяемость обеспечивается нашей логикой и ограничением базы.
Seed не имеет такого же журнала выполненных версий, как миграции. Это удобно для наполнения локального стенда, но требует осознанной реализации. Если seed каждый раз безусловно вставляет одинаковые записи, повторный запуск даст дубликаты или ошибку. Если он начинает с очистки таблицы, может потерять созданные вручную данные. Для рабочего окружения тестовый seed обычно вообще не нужен.
Справочник, обязательный для бизнес-логики, стоит рассматривать отдельно: его изменение должно быть согласовано с релизом и существующими ссылками на значения. В нашем примере DO NOTHING сохраняет уже имеющуюся подпись тега; это выбранное поведение, а не автоматическое обновление справочника. Для обновления подписей понадобится другой явный контракт. Устройство seed описано в официальном руководстве Phinx.
<?php
declare(strict_types=1);
use Phinx\Seed\AbstractSeed;
final class DemoTags extends AbstractSeed
{
public function run(): void
{
// Fixed demo values, SQLite-specific upsert; runs repeatedly without duplicates.
// Never truncate production tables merely to make a seed repeatable.
foreach ([['priority','Приоритет'],['regular','Постоянный'],['new','Новый']] as [$code,$label]) {
$this->execute('INSERT INTO client_tags (code,label) VALUES (?,?) ON CONFLICT(code) DO NOTHING', [$code,$label]);
}
}
}
change и up/down: как выбрать подходящий способ
Для простого добавления таблицы, колонки или индекса удобно использовать change: обратное структурное действие может быть построено библиотекой. В примерах это хорошо видно на contact_phone и idx_clients_email. Для переноса данных или изменения, которое требует отдельной логики обратного хода, понятнее написать up и down явно. При этом нельзя смешивать подходы в расчёте на последовательное выполнение всех трёх методов: присутствующий change имеет собственный приоритет.
Критерий выбора — не количество строк PHP, а возможность объяснить переход. Сформулируйте, что было до него, что стало после и какие данные необходимы для возврата. Если ответ «оригинал уже потерян», короткий down с преобразованием в обратном направлении не делает миграцию обратимой.
В Table API важно завершать действие подходящим вызовом create или update. Само описание колонки ещё не означает изменение базы. После написания файла полезно прогнать прямой и допустимый обратный переход на копии, а затем сравнить результат. Перечень поддерживаемых операций и ограничения находятся в документации миграций.
Связи и внешние ключи: целостность важнее удобной формы
Когда у клиента появляются заявки, одного поля client_id в таблице заявок недостаточно для гарантии связи. Внешний ключ может запретить запись с несуществующим клиентом и определить поведение при удалении родителя. Но сначала нужно выбрать смысл: удалять заявки вместе с клиентом, запрещать удаление или сохранять историю другим способом. Это решение влияет на продукт и данные поддержки.
В SQLite проверка внешних ключей настраивается для соединения. В шаблоне серверного подключения включён PRAGMA foreign_keys=ON; приложение должно применять согласованные настройки к своим соединениям тоже. Наличие определения FOREIGN KEY в SQL ещё не заменяет проверку фактического режима и тест попытки нарушить связь. Подробнее об этом — официальный раздел SQLite о внешних ключах.
Перед добавлением связи к старым данным найдите «осиротевшие» строки и решите их судьбу. Не удаляйте их молча в миграции только ради успешного релиза. Восемь записей этой мастерской не являются испытанием всех вариантов внешних ключей и каскадных операций: для своей модели заказов подготовьте отдельные сценарии создания, удаления и восстановления связанного набора.
SELECT orders.id, orders.client_id
FROM orders
LEFT JOIN clients ON clients.id = orders.client_id
WHERE orders.client_id IS NOT NULL
AND clients.id IS NULL;
-- Диагностика для своей схемы orders, которой нет в восьми учебных опытах.
Expand-contract: обновление приложения в несколько шагов
Предположим, поле name нужно разделить на display_name и legal_name. Самый рискованный ход — удалить name и сразу рассчитывать, что все экземпляры приложения уже обновлены. Очередь, старый worker или ещё не перезапущенный процесс могут продолжать обращаться к прежнему полю. Схема и код обновляются не всегда в одну физическую секунду.
Практичный порядок начинается с расширения: добавить новые поля, сохранив старые. Затем выпустить код, который умеет работать с переходным состоянием, заполнить новые значения и проверить полноту переноса. После переключения чтения и завершения работы старых процессов можно планировать удаление прежнего поля отдельным релизом. Так у каждого этапа есть наблюдаемый результат и собственный критерий готовности.
Если значение временно записывается в два поля, нужно заранее определить источник истины и поведение при ошибке записи. Простое «пишем в оба» без транзакции и контроля расхождений создаёт новую проблему. Phinx помогает оформить структурные этапы, но не координирует за вас фоновые задачи, кеши и экземпляры приложения.
Большие таблицы: порции, повтор и проверка результата
Небольшой UPDATE в учебной базе полезен для понимания механики, однако на сервере объём может быть на несколько порядков больше. Перед переносом оцените число затронутых строк, размер записей, свободное место и время удержания блокировок. Проверьте на копии данных не только успешное завершение, но и поведение приложения во время операции.
Для долгого заполнения нового поля часто подходит отдельная команда, которая берёт ограниченный диапазон идентификаторов, обновляет его и сохраняет позицию. Следующая порция начинается после последнего подтверждённого диапазона. Важно, чтобы повтор уже обработанной порции не портил данные, а критерий «готово» учитывал новые записи, которые появляются параллельно.
Процент прогресса сам по себе не доказывает завершение переноса. Нужны итоговые запросы: сколько строк осталось без нового значения, совпадают ли контрольные суммы или агрегаты, нет ли нарушенных связей. После остановки задачи полезно уметь понять, что было зафиксировано, и продолжить с безопасной точки. Такая подготовка обычно ценнее попытки поместить весь перенос в один эффектный файл миграции.
Предварительный просмотр SQL и его ограничения
Перед применением миграции полезно посмотреть, какие запросы собирается выполнить инструмент. Для этого существует dry-run. Он помогает заметить неожиданное удаление таблицы, неправильное имя индекса или обращение к другой схеме. Но это предварительный просмотр, а не полноценное испытание на реальных данных и не оценка длительности операции.
Например, unique может выглядеть совершенно правильно, пока в таблице остаются дубликаты. Изменение типа может оказаться проблемным только на конкретных значениях. Блокировка тоже появляется во взаимодействии с другими соединениями, а не на распечатке SQL. Поэтому следующий этап после просмотра — запуск на подходящей копии базы с проверкой результата.
PHP-файл миграции остаётся исполняемым кодом. Если разработчик добавил туда работу с файлами или внешним API, не следует считать dry-run универсальной песочницей для любых побочных действий этого кода. Проверяйте содержимое миграции на ревью. Команды и параметры можно сверить в официальном справочнике CLI.
php vendor/bin/phinx migrate --dry-run -c phinx.php -e development
php vendor/bin/phinx rollback --dry-run -c phinx.php -e development
# Запускать просмотр из подготовленного окружения выбранного опыта.
Резервная копия SQLite, которую действительно можно открыть
Для SQLite опасно считать любое копирование одного файла полноценным backup работающего приложения. При определённых режимах часть подтверждённых изменений находится в WAL, а приложение продолжает писать во время копирования. В проекте для учебной базы используется VACUUM INTO: SQLite создаёт согласованный снимок в новом файле, после чего скрипт открывает его и выполняет integrity_check.
Команда backup не перезаписывает прежнюю копию: имя содержит время и случайный суффикс. Она также выводит SHA256, чтобы затем можно было сверить файл. При этом контроль целостности говорит о структуре базы, а не о том, что вы скопировали именно нужное окружение или все ожидаемые бизнес-данные. Поэтому тест проекта дополнительно читает из копии три ожидаемых тега.
Для своего сервиса добавьте контрольные запросы по значимым сущностям и проверьте восстановление в отдельном месте. Официальное описание VACUUM INTO объясняет свойства снимка; для других способов смотрите SQLite Backup API. Прерванное создание копии не следует считать успешным backup.
php bin/backup.php expand development
# Пример результата: новый файл в var/, SHA256, Integrity: ok.
# До команды база expand/development должна существовать.
Восстановление: остановить писателей и проверить нужное состояние
План восстановления стоит продумать до миграции. Сначала определите допустимую потерю новых данных и время возврата сервиса. Если после резервной копии поступили заявки, полное восстановление старого файла уберёт их из активной базы. Эти изменения могут потребовать отдельного переноса, сверки или повторного приёма из надёжного журнала событий.
При восстановлении SQLite нужно остановить всех писателей, включая фоновые процессы, и закрыть соединения, которые держат старую базу. Сохраните проблемное текущее состояние для разбора. Откройте резервную копию отдельно, проверьте целостность и контрольные данные, затем согласованно переключите приложение на восстановленное состояние. Нельзя просто подменить основной файл под работающим сервисом, забыв про WAL, SHM и открытые соединения.
После переключения проверьте не только главную страницу: прочитайте существующую заявку, создайте тестовую запись в допустимом окружении, проверьте фоновые задачи и соответствие версии кода схеме. Возвращённый файл базы и возвращённый релиз приложения должны быть совместимы. Иногда исправление вперёд занимает меньше времени и лучше сохраняет новые данные, чем попытка любой ценой выполнить rollback.
Linux и VPS: расположение файлов и права
На сервере полезно отделить релиз приложения от постоянных данных. Например, код размещается в каталоге releases, а SQLite — в shared/data вне публичного корня. Обновление PHP-файлов тогда не переносит и не удаляет рабочую базу. Путь к ней передаётся через окружение, а шаблон конфигурации проверяет, что файл уже существует и доступен процессу.
Права нужны не только самому файлу SQLite, но и каталогу, где создаются служебные файлы. Вместо широкого chmod 777 назначьте владельца и группу, соответствующие вашему PHP-процессу и пользователю деплоя. Проверьте эти права отдельным тестом подключения от реального исполнителя. Успешная команда под root не доказывает, что приложение сможет работать после релиза.
Публичный веб-корень должен содержать только файлы, предназначенные для HTTP. Не публикуйте composer-конфигурацию, каталоги миграций, базу и резервные копии вместе с мастерской. В архиве public уже отделён от остальных частей. Для демонстрации достаточно статического размещения этой папки; для рабочего PHP-приложения используйте обычный серверный стек с предусмотренным процессом деплоя.
Практический порядок релиза на VPS
В архиве есть deploy/phinx.production.php — шаблон подключения к существующей собственной базе. Он намеренно не использует все восемь лабораторных каталогов: там есть конфликтующие и необратимые упражнения. В шаблоне оставлена только первоначальная миграция clients как отправная точка; перед использованием нужно указать каталог именно ваших проверенных миграций.
Порядок работы начинается с проверки назначения подключения и резервной копии. Затем смотрят status и SQL, оценивают совместимость старого кода с новой схемой и окно записи. После этого один исполнитель применяет миграции, снова проверяет статус и выполняет контрольные операции приложения. Ошибка любого обязательного этапа должна остановить дальнейшее переключение релиза.
Команды ниже являются шаблоном для адаптации, а не подтверждением выполненного обновления вашего production. Укажите существующие пути и новое имя файла backup. Файлы резервных копий следует хранить с отдельными правами и по своей политике хранения; один снимок рядом с приложением не защищает от потери всего диска или сервера.
export APP_SQLITE_PATH=/srv/crm/shared/data/crm.sqlite
export APP_BACKUP_PATH=/srv/crm/backups/crm-before-release.sqlite
php deploy/backup-production.php
php vendor/bin/phinx status -c deploy/phinx.production.php -e production
php vendor/bin/phinx migrate --dry-run -c deploy/phinx.production.php -e production
# После проверки копии, данных и окна записи:
flock -n /srv/crm/shared/migrate.lock php vendor/bin/phinx migrate -c deploy/phinx.production.php -e production
php vendor/bin/phinx status -c deploy/phinx.production.php -e production
Один исполнитель миграций и несколько серверов
Миграции не следует запускать при каждом открытии сайта или старте каждого worker. Несколько процессов могут одновременно увидеть одну и ту же неприменённую версию и начать изменение. Факт наличия phinxlog не заменяет организацию единственного исполнителя релиза. Для одного Linux-сервера пример использует flock с общим для этого проекта файлом блокировки.
Такая блокировка помогает только процессам, которые соблюдают тот же договор. Другой скрипт без flock сможет войти в базу; соседний сервер со своим локальным файлом тоже не увидит чужой lock. В многосерверной схеме назначьте отдельную задачу деплоя или согласованную общую координацию. Сам механизм нужно проверить в вашей инфраструктуре.
На время миграции необходимо понимать, кто ещё пишет данные: HTTP-обработчики, очереди, расписания, интеграционные команды. Иногда достаточно совместимого расширения схемы, иногда нужен режим обслуживания. Если приложение распределено, возврат одной версии кода не означает, что все старые процессы уже остановлены. Список исполнителей и порядок их обновления относятся к тому же плану релиза, что и команда Phinx.
Существующий проект: как начать без пересоздания таблиц
Если база уже работает несколько лет, нельзя просто создать миграцию начальной схемы и выполнить её поверх имеющихся таблиц. Сначала зафиксируйте фактическую структуру и найдите расхождения между окружениями. Ручные изменения за это время могли сделать development, staging и production различными, даже если код приложения совпадает.
Полезно воспроизвести текущую схему в пустой тестовой базе и сравнить её с рабочей копией: поля, типы, значения по умолчанию, индексы и связи. После этого выбирается понятный baseline — исходная точка истории для дальнейших изменений. Порядок принятия существующей схемы должен быть документирован и проверен отдельно. Не вставляйте строку в phinxlog только ради зелёного status.
Первые новые миграции лучше делать небольшими: одно совместимое поле или понятный индекс. Это позволяет проверить процесс команды, сборки, резервного копирования и контроля результата на ограниченном изменении. Полная перестройка схемы одновременно с первым внедрением инструмента усложняет поиск причин ошибки и восстановление.
Совместная работа: неизменяемая история и конфликтующие ветки
Миграции стоит хранить в том же репозитории, что и код приложения. На ревью проверяются имена, порядок, данные до изменения, возможные блокировки и обратный план. Если две ветки добавили похожее поле или по-разному переименовали таблицу, конфликт не всегда будет виден как обычный конфликт текста. Он проявится при последовательном выполнении обоих файлов.
Перед объединением веток полезно прогнать полный путь на пустой базе и переход с предыдущего релиза на копии данных. Это два разных испытания. Первое обнаруживает проблемы первоначальной установки, второе — ошибки переноса уже существующего состояния. Добавленный в старую миграцию столбец может пройти первое и сломать второе, поэтому опубликованная история должна оставаться неизменной.
Проверяйте и версии файлов. Время в имени помогает упорядочить миграции, но не заменяет согласование зависимостей между ними. Если новая миграция обращается к колонке из другой ветки, убедитесь, что эта колонка появится раньше во всех поддерживаемых путях обновления. При необходимости измените ещё не выпущенную ветку до публикации релиза.
Какие проверки включить в свой проект
Для миграции недостаточно проверить, что процесс завершился нулём. Сравните нужные столбцы и индексы, количество и значения затронутых строк, ограничения и версии в журнале. Если обещаете обратимость, выполните обратный переход и проверьте то же состояние. Если изменение необратимо, полезно проверить понятный отказ и сохранение корректной отметки об уже выполненной версии.
В нашем проекте автоматические проверки читают SQLite повторно после CLI, подтверждают сохранение клиентов при конфликте unique, откат UPDATE при ошибке INSERT, появление индекса в плане и отсутствие дубликатов после двух запусков seed. Отдельно открывается резервная копия. В recording.json находятся хеши использованных миграций и Composer lock, чтобы связать запись с конкретным кодом.
Для своей базы добавьте сценарии с пустыми значениями, максимальными длинами, старыми некорректными записями и конкурирующими запросами там, где это существенно. Не подменяйте такие проверки случайными тестовыми данными без ожидаемого результата. Небольшой набор специально подобранных конфликтов часто показывает больше, чем большая таблица одинаково корректных строк.
composer capture
composer test
# После изменения UI, при установленном Node >=24.15:
npm ci
npm run build
npm test
Типичные проблемы и как искать причину
Если Phinx не находит класс миграции, сопоставьте имя PHP-класса и имя файла после версии. Если status показывает не ту историю, проверьте выбранное окружение и абсолютный путь к базе. SQLite легко создаёт новый файл при ошибочном пути, поэтому пустой список таблиц иногда означает не потерю данных, а подключение к другому файлу. Серверный шаблон специально требует существующий файл.
При «database is locked» ищите активных писателей и длинные транзакции, а не бесконечно увеличивайте ожидание. Busy timeout помогает пережить краткую конкуренцию, но не исправляет зависший процесс или слишком долгую операцию. После прерванной миграции сначала посмотрите фактическую схему, строки и журнал, затем выбирайте повтор, исправление вперёд или восстановление.
При конфликте unique изучите данные, при отсутствии драйвера — окружение PHP, при отказе доступа — владельца и каталог базы. Эти причины относятся к разным слоям. Попытка решать всё повторным migrate затрудняет диагностику и может добавить новые побочные действия, если ваша миграция выходит за пределы транзакции базы.
Что взять из проекта в собственное приложение
Перенесите сам подход: зафиксированные зависимости, явное окружение, маленькие миграции, отдельные тестовые данные, проверяемый backup и один исполнитель обновления. Из кода полезны образцы добавления колонки и индекса, явные up/down, чтение состояния SQLite и повторяемое заполнение справочника. Настройки путей и правила данных нужно адаптировать к своему сервису.
Не переносите лабораторный reset, намеренные ошибки и необратимую нормализацию в рабочую историю без пересмотра. Они нужны для демонстрации наблюдаемого поведения. Аналогично, конфигурация восьми независимых баз помогает сравнивать опыты, но обычному приложению часто достаточно одной последовательности миграций на окружение. Сохраните ясность процесса, а не случайные детали стенда.
Готовая веб-мастерская работает с локальными CSS и JavaScript, не требует внешнего CDN и может быть размещена как статическая страница. Для повторения PHP-результатов используйте архив и Composer. Если меняете миграции, создайте новую запись capture и запустите проверки: старая картинка результата не является доказательством работы нового кода.
Связанные материалы и первоисточники
Если только начинаете работать с базами в PHP, сначала пройдите нашу главу «SQL и PDO с нуля»: она связывает таблицы, параметры запроса и вывод данных на странице. Далее пригодится «PDO_SQLITE: локальный каталог, доступ и связанный импорт». Эти ссылки ведут прямо к соответствующим разделам большого PHP-мануала на сайте.
Для практики запросов и интерактивных примеров есть отдельная мастерская SQLite. Установку пакетов, lock-файл и основные команды разбирает материал «Composer в PHP: от первой команды до рабочего проекта». Вместе эти материалы позволяют перейти от первого подключения к воспроизводимому обновлению приложения.
Для проверки конкретного API используйте руководство Phinx 0.16, исходный код проекта и документацию выбранной СУБД. Все восемь записей здесь получены на SQLite; MySQL и PostgreSQL требуют собственной проверки SQL, транзакций, блокировок и схемы развёртывания. Наличие общего PHP API не означает одинакового поведения всех баз в каждом частном случае.


