Сайт лежить, у логах фатал у плагіні кешування, останній деплой був годину тому і містив правку одного шаблону. Причина зазвичай не в правці. Хтось із доступом до адмінки натиснув «Оновити» два тижні тому, оновлена версія плагіна опинилась тільки на продакшн-диску, а деплой привів дерево файлів до того стану, який лежить у git. Формально це відкат. На ділі стара версія плагіна опинилась над базою, яку вже перебудувала нова.
Чому оновлення з адмінки ламають деплой
WordPress проєктувався так, щоб сайт міг сам себе обслуговувати: WP_Filesystem пише в wp-content/plugins, розпаковує zip, підміняє каталоги. Для сайту без розробника це зручно й геть несумісне з будь-яким деплоєм, де файли беруться з репозиторію. Виходить два джерела істини: git і живий диск. Розходження між ними видно лише тоді, коли деплой уже перезаписав каталог.
Гірше, що оновлення плагіна майже ніколи не обмежується файлами. При активації або на хуку upgrader_process_complete плагін читає власну опцію версії (щось у стилі myplugin_db_version), виконує dbDelta(), додає колонку, конвертує серіалізовані дані, пише нове значення опції. База поїхала вперед. Коли деплой повертає код назад, опція лишається новою: міграцій «вниз» у WordPress не існує, і плагін або тихо не виконує потрібний апгрейд, або падає на колонці, якої в його версії ще немає.
Додайте туди ж ручні правки в темі через вбудований редактор, плагін, поставлений «на тест» і забутий, і різницю в наборі плагінів між стейджингом і продом. Нормальний вихід один: список файлів визначає composer.lock, а продакшн не має права його змінювати. Саме це й робить Bedrock, стандартна структура WordPress-проєкту від Roots, де ядро, плагіни й теми є пакетами Composer.
Структура Bedrock
Bedrock ставиться як шаблон проєкту: composer create-project roots/bedrock. Дерево виглядає так:
composer.json
composer.lock
.env # не в git
wp-cli.yml
config/
application.php # спільний конфіг
environments/
development.php
staging.php
production.php
web/ # document root веб-сервера
index.php
wp-config.php # вантажить корневий wp-config.php і wp-settings.php
wp/ # ядро, встановлює Composer, у git не лежить
app/ # те, що раніше було wp-content
plugins/
mu-plugins/
themes/
uploads/ # не в git
vendor/
wp-config.php # require config/application.php
Два зсуви тут принципові. Перший стосується document root: ним стає web/, а ядро лежить у підкаталозі web/wp. Тому WP_SITEURL дорівнює WP_HOME . '/wp', а wp-login.php живе за /wp/wp-login.php. Все, що поза web/, з інтернету недосяжне: .env, vendor, config.
Другий зсув: wp-content перейменований у web/app через WP_CONTENT_DIR і WP_CONTENT_URL. Ядро приходить пакетом roots/wordpress, який тягне roots/wordpress-no-content, те саме ядро, але без теки wp-content з дефолтними темами й Hello Dolly. Тека web/wp повністю керована Composer і додана в .gitignore, разом з web/app/uploads і каталогами плагінів, якими володіє Composer. Свою тему й свої mu-плагіни, звісно, тримаємо в git.
Розкладає пакети по правильних місцях composer/installers через секцію extra:
"extra": {
"installer-paths": {
"web/app/mu-plugins/{$name}/": ["type:wordpress-muplugin"],
"web/app/plugins/{$name}/": ["type:wordpress-plugin"],
"web/app/themes/{$name}/": ["type:wordpress-theme"]
},
"wordpress-install-dir": "web/wp"
}
Плагіни, які мусять завантажуватись першими (перемикачі мультисайту, фільтри на pre_option_*, аварійні kill switch), кладуть у mu-plugins. WordPress вантажить звідти лише файли першого рівня, тому Bedrock ставить roots/bedrock-autoloader, mu-плагін, який підхоплює і вкладені каталоги.
Плагіни з WPackagist
WPackagist дзеркалить каталог wordpress.org у форматі Composer-репозиторію. Підключається одним блоком:
"repositories": [
{
"type": "composer",
"url": "https://wpackagist.org",
"only": ["wpackagist-plugin/*", "wpackagist-theme/*"]
}
]
only тут не косметика: без нього Composer питатиме WPackagist про кожен ваш пакет і роздує час резолву. Імена пакетів збігаються зі слагами з каталогу: composer require wpackagist-plugin/redirection поставить плагін зі слагом redirection у web/app/plugins/redirection.
Головна пастка тут у версіях. Плагіни wordpress.org живуть за власними правилами, і мінорний реліз там спокійно може змінити структуру таблиць або прибрати хук. Тому обмеження типу ^4.0 для плагіна ризикованіше, ніж для нормальної бібліотеки: беріть ~4.9.0 чи точну версію для всього, що критичне для бізнесу, і оновлюйте свідомо. Джерелом істини на сервері все одно є composer.lock, тому реальне оновлення виглядає як composer update wpackagist-plugin/redirection -W локально, прогін на стейджингу й коміт лок-файлу.
Якщо плагін має бути mu-плагіном, перевизначте шлях по імені пакета, а не по типу:
"web/app/mu-plugins/{$name}/": ["wpackagist-plugin/some-switch"]
Платні плагіни в WPackagist не потраплять. Варіанти: власний приватний Composer-репозиторій (наприклад, SatisPress, який віддає встановлені плагіни як пакети), або пакет, оголошений вручну з посиланням на zip вендора. Ліцензійний ключ у composer.json не зберігають: Composer підставляє змінні середовища прямо в URL:
{
"type": "package",
"package": {
"name": "vendor/premium-plugin",
"version": "3.1.2",
"type": "wordpress-plugin",
"dist": {
"type": "zip",
"url": "https://example.com/download?token={%PREMIUM_PLUGIN_KEY}"
}
}
}
Мінус такого пакета: версію ви оновлюєте руками, бо Composer не бачить списку релізів. Це нудно, зате явно видно в історії git.
Змінні середовища і конфіг
.env у корені читається через vlucas/phpdotenv, значення дістаються хелпером env() з oscarotero/env, а константи оголошуються через Roots\WPConfig\Config:
use Roots\WPConfig\Config;
Config::define('WP_HOME', env('WP_HOME'));
Config::define('WP_SITEURL', Config::get('WP_HOME') . '/wp');
Config::define('WP_DEBUG_LOG', env('WP_DEBUG_LOG') ?? false);
Config::define('DISALLOW_FILE_EDIT', true);
Config::apply();
Різниця з define() у тому, що Config тримає значення в масиві до apply() і кричить, якщо ту саму константу оголошено двічі з різними значеннями. Конфіг для оточення (config/environments/production.php) виконується після application.php і може перевизначати вже задане, бо apply() ще не викликаний; прибрати константу можна через Config::remove().
Мінімальний набір у .env: DB_NAME, DB_USER, DB_PASSWORD, DB_HOST, WP_ENV, WP_HOME, вісім солей (AUTH_KEY, SECURE_AUTH_KEY, LOGGED_IN_KEY, NONCE_KEY і чотири відповідні *_SALT). Солі генеруються один раз на оточення і не збігаються між стейджингом і продом, інакше сесійна cookie з одного середовища валідна в іншому.
Оскільки WP_HOME і WP_SITEURL приходять константами, вони перекривають опції home і siteurl у базі. Практичний наслідок: копію продакшн-бази можна розгорнути локально без search-replace по домену. Поля в адмінці стають нередагованими, і це правильно.
У продакшн-конфігу обов'язкові дві речі. DISALLOW_FILE_MODS (true) прибирає з адмінки встановлення й оновлення плагінів, тем і ядра разом з кнопками, тому дійти до розходження з git уже немає як. AUTOMATIC_UPDATER_DISABLED (true) вимикає фонові автооновлення. Задайте й WP_ENVIRONMENT_TYPE: цю константу ядра читає wp_get_environment_type(), і на неї спираються самі плагіни, тоді як WP_ENV лишається суто змінною Bedrock для вибору файла конфігу.
Деплой і оновлення
Bedrock не приносить свого деплоєра, він робить сайт придатним до звичайного build-and-release. Артефакт збирається не на проді:
composer install --no-dev --prefer-dist --no-interaction --optimize-autoloader
npm ci && npm run build
Далі каталог релізу кладеться поряд з попередніми, спільні шляхи підв'язуються симлінками (.env, web/app/uploads), і симлінк current перемикається одним ln -sfn. Після перемикання:
wp core update-db # і --network для мультисайту
wp cache flush
wp rewrite flush
Про wp core update-db забувають найчастіше. Composer оновив файли ядра, але db_version в опціях лишився старим; доки апгрейдер не пройшов, WordPress або редіректить адмінів на /wp/wp-admin/upgrade.php, або працює на схемі, що не відповідає коду. Те саме стосується плагінів зі своїми міграціями: вони чекають запиту в адмінку, тому після релізу зайдіть явно на /wp/wp-admin/ (або викличте їхню CLI-команду, якщо є), щоб не дізнаватись про недороблену міграцію від першого відвідувача.
Свої нюанси в drop-in'ів. object-cache.php і advanced-cache.php лежать не в теці плагіна, а в корені web/app, і кладе їх туди плагін через запис на диск. У read-only релізі це не спрацює: файли треба або тримати в git, або копіювати кроком деплою після composer install. Так само перевірте плагіни, які пишуть у власний каталог (генератори sitemap, критичного CSS, локальних логів): їм потрібен шлях у uploads або окремий writable каталог зі спільних, інакше кожен реліз стирає їхній стан.
Оновлення тепер виглядає так само, як у будь-якому PHP-проєкті: composer update roots/wordpress для ядра, composer update wpackagist-plugin/... -W для плагіна, composer.lock у коміт, деплой на стейджинг з копією продакшн-бази, потім прод. Код відкатити легко, достатньо перемкнути симлінк назад. З базою так не вийде, тому реліз з міграціями завжди йде після бекапу, а не після сподівання.