Як перенести застарілий процедурний PHP на Laravel: стратегія поступового заміщення

Перекладено ШІ 0 JustSteveKing 05 серпня, 2026

Повне переписування legacy-проєкту часто стає фатальною помилкою, проте існує значно безпечніший шлях оновлення системи. Розповідаємо, як за допомогою патерну Strangler Fig поступово перенести код на Laravel, не зупиняючи розробку нових feature.

Переписування проекту з нуля — це найсміливіше і водночас найгірше рішення, яке може прийняти команда розробників. Аргументи завжди звучать переконливо: поточна система неповоротка, розробка сповільнилася, тому ми ставимо нові фічі на паузу, все правильно перебудовуємо і за шість місяців повертаємося з ідеальним кодом. На практиці ж через чотири місяці бізнесу терміново потрібна функція, яку стара система ще може підтримувати, а нова — ні. У результаті «реврайт» або випускають напівготовим, або тихо закривають. Тепер у вас замість однієї системи — дві, і обидві треба підтримувати.

Ризик зростає, коли ви замінюєте не старий 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 непомітно для себе.

Що і в якому порядку мігрувати

Коли підключення запитів, сесії та моделі готові, можна починати «душити» стару систему. Послідовність важливіша за швидкість.

  1. Завдання без інтерфейсу: черги, обробники webhook, експорт звітів, cron-завдання. У них чіткі входи та виходи, немає стану сесії та ризику візуальних багів.
  2. Сторінки лише для читання: списки або детальні перегляди. Ризик мінімальний: якщо щось піде не так, це відразу помітно і легко відкотити. Тут ви відшліфуєте архітектуру.
  3. Шляхи запису (Write paths): створення замовлень, оформлення кошика, налаштування профілю. Тут живе бізнес-логіка і недокументована поведінка. Перед переписуванням вивчіть старий код і випишіть усі побічні ефекти.
  4. Views: заміна include 'header.php' на Blade-лейаути відбудеться природно в процесі міграції сторінок.
  5. Видалення: крок, про який усі забувають. Коли маршрут повністю перенесено в Laravel, видаліть старий файл. Якщо його залишити, через півтора року ніхто не буде впевнений, чи можна його чіпати. Видалення — єдиний доказ того, що міграція завершена.

Як зрозуміти, чи це працює

Міграція без метрик може тривати вічно, бо завжди знайдеться щось терміновіше. Тримайте себе в тонусі за допомогою двох показників:

  • Кількість запитів, що все ще потрапляють у legacy fallback. Логуйте це і виведіть на графік. Лінія, що прагне до нуля — найкращий звіт для керівництва.
  • Кількість старих файлів, що залишилися. Показник грубий, але він наочний і рухається лише в одному напрямку.

Цей патерн працює не завдяки технічній елегантності, а тому, що він не вимагає від бізнесу «заморозки» розробки. Кожного тижня ви випускаєте щось нове, і кожного тижня стара система стає трохи меншою. Нікому не потрібно затамовувати подих на пів року, бо результат видно вже за тиждень.

У цьому і полягає весь секрет. Не в героїчному переписуванні, а в повільному, нудному, але невпинному обмеженні простору, де старий код ще має право на життя.

Популярні

Інше, що варто прочитати

79 Оновлено 26 червня, 2026

Laravel Boost — ваш стартовий набір для програмування з використанням штучного інтелекту

Вперше у світі Laravel з'являється можливість, яка значно спростить ваше повсякденне програмування завдяки новому пакету Laravel Boost. Читайте статтю, щоб дізнатися, як посилена інтеграція штучного інтелекту може підвищити ефективність вашої роботи та оптимізувати створення проектів у Laravel

14 Оновлено 25 червня, 2025

Отримання параметрів команди в Laravel Artisan

Laravel спрощує доступ до аргументів та опцій у ваших кастомних командах Artisan, дозволяючи легко отримувати та валідувати параметри. Дізнайтеся, як ці вбудовані допоміжні методи можуть покращити ваш процес розробки!

10 Оновлено 26 червня, 2026

Остаточний посібник з вебхуків у Laravel

Сучасні веб-додатки вимагають миттєвого обміну інформацією, і вебхуки стають незамінними помічниками у цьому процесі. Пориньте у світ вебхуків у Laravel і дізнайтеся, як легко реалізувати цю технологію у своїх проєктах, забезпечуючи безпеку та продуктивність