Переписування проекту з нуля — це найсміливіше і водночас найгірше рішення, яке може прийняти команда розробників. Аргументи завжди звучать переконливо: поточна система неповоротка, розробка сповільнилася, тому ми ставимо нові фічі на паузу, все правильно перебудовуємо і за шість місяців повертаємося з ідеальним кодом. На практиці ж через чотири місяці бізнесу терміново потрібна функція, яку стара система ще може підтримувати, а нова — ні. У результаті «реврайт» або випускають напівготовим, або тихо закривають. Тепер у вас замість однієї системи — дві, і обидві треба підтримувати.
Ризик зростає, коли ви замінюєте не старий framework, а хаотичний код. Міграція з Symfony 2 на Laravel — процес неприємний, але зрозумілий. Там є контролери, routing, service container; концепції легко зіставити. Процедурний моноліт не дає такої розкоші. Ви отримуєте глобальний стан прямо з $_GET та $_POST, виклики mysqli_query впереміш із HTML, header('Location: ...') посеред бізнес-логіки та includes, які побічним ефектом підтягують половину додатка. Зачепитися немає за що, і саме тому розробники вирішують, що єдиний вихід — почати спочатку.
Але є інший варіант. Поставте Laravel «перед» старим додатком, переносьте по одному маршруту за раз, а процедурному коду залиште все, до чого ще не дійшли руки. Це патерн «фікус-душитель» (strangler fig pattern), названий на честь ліани, що обплітає дерево-господаря, поступово перебирає на себе його функції та залишає по собі лише порожню оболонку. Головне, що протягом усього процесу дерево продовжує стояти.
Laravel як «парадні двері»
Перший крок — визначити, хто володіє вхідним запитом. Це має бути Laravel, причому з першого дня, навіть якщо він поки не обробляє жодного маршруту.
Веб-сервер використовує директорію public у Laravel як корінь документа. Все, що Laravel не розпізнає, передається на вхід старій системі. Коли ви мігруєте endpoint, він перестає потрапляти у стару частину, при цьому URL для клієнта залишається незмінним.
Запит
|
v
Nginx -> Laravel public/index.php
|
+-- маршрут знайдено? так -> обробляє Laravel
|
+-- ні -> перехід на legacy/index.php
У Nginx це виглядає приблизно так:
server {
listen 80;
server_name my-app.test;
root /var/www/my-app/laravel/public;
index index.php;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location /legacy/ {
root /var/www/my-app;
try_files $uri $uri/ @legacy;
location ~ ^/legacy/.+\.php$ {
root /var/www/my-app;
try_files $fastcgi_script_name =404;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
}
}
location @legacy {
rewrite ^/legacy/(.*)$ /legacy/index.php?page=$1 last;
}
location ~ ^/index\.php(/|$) {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
}
}
Варто звернути увагу на дві деталі, на які я свого часу витратив чимало часу.
По-перше, у блоці legacy використовується root замість alias. Використання alias здається логічним, коли ви мапите префікс URL на папку поза основним коренем, але він не працює з try_files. Це баг Nginx (ticket 97), відкритий ще з 2010 року. Значення $uri не переписується через alias, тому try_files $uri знову додає префікс і шукає щось на кшталт /var/www/my-app/legacy/legacy/whatever. Це тихо ламається, створюючи враження, що не працює шлях, а не сам механізм. Якщо можливо, розташуйте директорії так, щоб старий додаток був під спільною батьківською папкою — тоді root вирішить проблему. Якщо ні — використовуйте regex у location і будуйте шлях самостійно.
По-друге, це try_files $fastcgi_script_name =404 у PHP-блоці та обмежений патерн ^/index\.php(/|$) для Laravel. Звичайний location ~ \.php$, який передає в FPM все, що закінчується на .php, історично призводив до вразливостей RCE (remote code execution). Запит на /uploads/avatar.jpg/x.php міг призвести до виконання завантаженого файлу. У старих додатках папки із завантаженнями часто лежать прямо в web root, тому це критично.
Альтернативний підхід — обробляти fallback всередині самого Laravel через catch-all route, який проксує запит до старого додатка. Це повільніше, бо кожен запит ініціалізує framework, але так ви отримуєте таблицю маршрутизації у версійному контролі, де її можна читати, тестувати та логувати. На проектах із великим трафіком це виправдано протягом перших місяців.
Головна перешкода: сесії
Маршрутизація — це просто. Справжні проблеми починаються на етапі аутентифікації.
Процедурні додатки викликають session_start() і пишуть у $_SESSION['user_id']. У Laravel власна обробка сесій, свої cookie, шифрування та уявлення про авторизованого користувача. Якщо нічого не змінити, користувач, що увійшов через стару систему, потрапить на мігрований маршрут Laravel і буде перенаправлений на форму входу. Подвійна авторизація — це не міграція, а технічний збій.
Найпопулярніша порада — зробити спільне сховище сесій. Я спробую вас відмовити: це набагато складніше, ніж здається.
У PHP немає вбудованого обробника сесій для бази даних. Значення session.save_handler приймає files, redis, memcached або user. Останнє означає, що вам доведеться писати клас, який реалізує SessionHandlerInterface. Тобто перший крок — це вже кастомний код у системі, яку ви не хотіли чіпати.
Другий крок ще гірший. Драйвер сесій Laravel у БД не зберігає нативний PHP-об’єкт. Він зберігає base64_encode(serialize($attributes)) у колонці payload. І Laravel не вважає вас авторизованим лише тому, що в payload є user_id. SessionGuard шукає ключ, що складається з login_, назви guard та SHA1 від назви класу. Для стандартного вебу це щось на кшталт login_web_59ba36addc2b2f9401580f014c7f58ea4e30989d. Щоб ваш старий код створив сесію, яку прийме Laravel, йому доведеться відтворити цей формат і ключ. Це можливо, але це прив’язує старий код до внутрішніх механізмів Laravel, які не є частиною публічного API.
Тому є два чесних варіанти.
Перший (мій фаворит): перевернути проблему. Зробіть аутентифікацію першим, що ви мігруєте. Laravel тепер володіє логіном, логаутом та сесією, а старий додаток адаптується під ці дані. Це простіше, ніж кастомний save handler: замість перевірки $_SESSION['user_id'] старий код звертається до функції, яка читає сесію Laravel або підписану cookie. Ви робите цю складну роботу один раз на початку, і кожна наступна міграція вже має готовий контекст авторизації.
Другий варіант — читати стару сесію в middleware, якщо логін поки не можна перенести:
namespace App\Http\Middleware;
use App\Models\User;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
final class BridgeLegacySession
{
public function handle(Request $request, Closure $next): Response
{
if (Auth::check()) {
return $next($request);
}
$cookie = config('legacy.session_cookie', 'PHPSESSID');
if (! $request->hasCookie($cookie)) {
return $next($request);
}
$session = DB::connection('legacy')
->table('sessions')
->where('id', $request->cookie($cookie))
->first();
if (! $session) {
return $next($request);
}
$userId = $this->extractUserId($session->data);
if ($userId && $user = User::find($userId)) {
Auth::login($user);
}
return $next($request);
}
private function extractUserId(string $data): ?int
{
$vars = [];
$offset = 0;
while ($offset < strlen($data)) {
$delimiter = strpos($data, '|', $offset);
if ($delimiter === false) {
break;
}
$name = substr($data, $offset, $delimiter - $offset);
$offset = $delimiter + 1;
$value = unserialize(substr($data, $offset));
$vars[$name] = $value;
$offset += strlen(serialize($value));
}
return $vars['user_id'] ?? null;
}
}
Цей парсер потрібен тому, що формат серіалізації сесій PHP відрізняється від звичайного serialize(). Це послідовність пар ім’я|серіалізоване_значення, де ім'я не має префікса довжини. Тому не можна просто передати все в unserialize(), потрібно проходити по байтах.
Будьте обережні: цей підхід має свої слабкі місця. Символ вертикальної риски | всередині значення сесії — це нормально, бо цикл рухається на довжину strlen(serialize($value)). Проблема виникає, якщо риска з'явиться в ключі сесії. Також зверніть увагу на unserialize(): поведінка PHP при обробці рядка із зайвими даними в кінці змінювалася від версії до версії.
Ще один критичний момент: якщо в сесії лежить серіалізований об’єкт класу, якого немає в Laravel, unserialize() поверне __PHP_Incomplete_Class, що призведе до помилок далі за кодом. Тому використовуйте обробку помилок, логуйте невдачі та сприймайте це як тимчасовий «міст», а не постійну інфраструктуру.
Eloquent та успадкована схема БД
У старих проектах часто зустрічаються дивні конвенції: префікси tbl_, первинні ключі usr_id_pk, unix-таймстампи в колонках dt_created і прапорець deleted, який може бути то 0, то NULL.
Не намагайтеся відразу виправити схему. Це останній крок, а не перший, бо під час міграції обидва додатки працюють з одними таблицями. Eloquent легко адаптується:
namespace App\Models;
use Illuminate\Foundation\Auth\User as Authenticatable;
final class User extends Authenticatable
{
protected $connection = 'legacy';
protected $table = 'tbl_users';
protected $primaryKey = 'usr_id_pk';
public const CREATED_AT = 'usr_created_dt';
public const UPDATED_AT = 'usr_updated_dt';
protected $fillable = [
'usr_email',
'usr_password',
'usr_name',
];
protected function casts(): array
{
return [
'usr_created_dt' => 'timestamp',
'usr_updated_dt' => 'timestamp',
];
}
public function getAuthPassword(): string
{
return $this->attributes['usr_password'];
}
}
Casts тут критично важливі. Вказання CREATED_AT каже Eloquent, які колонки оновлювати, але не каже, як їх читати. Якщо там зберігаються unix-таймстампи замість типу datetime, без касту timestamp ваші порівняння дат перетворяться на математику з рядками та генеруватимуть нісенітницю.
Якщо первинний ключ — це рядок або UUID, встановіть $incrementing = false та $keyType = 'string', інакше Eloquent спробує привести його до integer. Якщо таймстампів взагалі немає — вимкніть їх через $timestamps = false.
Можна піти далі й сховати назви колонок за accessors:
use Illuminate\Database\Eloquent\Casts\Attribute;
protected function email(): Attribute
{
return Attribute::make(
get: fn (): string => $this->usr_email,
);
}
Це варто робити для найпопулярніших колонок. Коли ви нарешті наважитеся перейменувати поле в БД, вам доведеться змінити код лише в одному місці, а не в сотнях викликів по всьому проекту. Модель стає перекладачем між хаосом у БД та зрозумілою мовою вашого домену.
Щодо паролів: старі додатки часто використовують MD5 або SHA1 без солі. Hash::check() у Laravel видасть помилку, намагаючись перевірити їх як bcrypt. Вам доведеться визначати формат вручну:
if (str_starts_with($user->getAuthPassword(), '$2y$')) {
$valid = Hash::check($plain, $user->getAuthPassword());
} else {
$valid = hash_equals($user->getAuthPassword(), md5($plain));
}
if ($valid && Hash::needsRehash($user->getAuthPassword())) {
$user->forceFill([
'usr_password' => Hash::make($plain),
])->save();
}
Використовуйте hash_equals() для захисту від атак за часом (timing attacks) і робіть rehash при кожному успішному вході. За кілька місяців більшість активних користувачів «переїдуть» на bcrypt непомітно для себе.
Що і в якому порядку мігрувати
Коли підключення запитів, сесії та моделі готові, можна починати «душити» стару систему. Послідовність важливіша за швидкість.
- Завдання без інтерфейсу: черги, обробники webhook, експорт звітів, cron-завдання. У них чіткі входи та виходи, немає стану сесії та ризику візуальних багів.
- Сторінки лише для читання: списки або детальні перегляди. Ризик мінімальний: якщо щось піде не так, це відразу помітно і легко відкотити. Тут ви відшліфуєте архітектуру.
- Шляхи запису (Write paths): створення замовлень, оформлення кошика, налаштування профілю. Тут живе бізнес-логіка і недокументована поведінка. Перед переписуванням вивчіть старий код і випишіть усі побічні ефекти.
- Views: заміна
include 'header.php'на Blade-лейаути відбудеться природно в процесі міграції сторінок. - Видалення: крок, про який усі забувають. Коли маршрут повністю перенесено в Laravel, видаліть старий файл. Якщо його залишити, через півтора року ніхто не буде впевнений, чи можна його чіпати. Видалення — єдиний доказ того, що міграція завершена.
Як зрозуміти, чи це працює
Міграція без метрик може тривати вічно, бо завжди знайдеться щось терміновіше. Тримайте себе в тонусі за допомогою двох показників:
- Кількість запитів, що все ще потрапляють у legacy fallback. Логуйте це і виведіть на графік. Лінія, що прагне до нуля — найкращий звіт для керівництва.
- Кількість старих файлів, що залишилися. Показник грубий, але він наочний і рухається лише в одному напрямку.
Цей патерн працює не завдяки технічній елегантності, а тому, що він не вимагає від бізнесу «заморозки» розробки. Кожного тижня ви випускаєте щось нове, і кожного тижня стара система стає трохи меншою. Нікому не потрібно затамовувати подих на пів року, бо результат видно вже за тиждень.
У цьому і полягає весь секрет. Не в героїчному переписуванні, а в повільному, нудному, але невпинному обмеженні простору, де старий код ще має право на життя.