Команда из четырёх человек собирает на Laravel первый рабочий прототип за две недели: миграции базы, авторизация, панель администратора, письма через очередь. Через три года тот же проект насчитывает сотни таблиц и больше миллиона строк кода, а главная страница открывается не за 80 миллисекунд, а за три секунды. Траектория до боли знакомая, и Laravel в ней одновременно не виноват и виноват. Фреймворк отдаёт скорость на старте почти бесплатно, а платой за неё становится дисциплина, которой у загруженной команды обычно не остаётся. Дальше по порядку: где именно Laravel ускоряет разработку, где его удобные механизмы превращаются в медленные запросы и что реально возвращает быстрые ответы на зрелом проекте.

Сколько Laravel экономит команде в первые месяцы разработки крупного проекта

На старте у фреймворка почти нет равных по скорости сборки. Схема базы версионируется миграциями, модели пишутся в несколько строк, авторизация закрывается готовыми механизмами, валидация форм описывается декларативно, а письма, задачи по расписанию и очередь уже встроены. Пакетная экосистема закрывает типовые потребности: биллинг, полнотекстовый поиск, уведомления, мониторинг очередей, интеграции с платёжными системами. Команда не тратит недели на выбор библиотек и их склейку между собой, она сразу пишет бизнес-логику.

Schema::create('orders', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained();
    $table->decimal('total', 12, 2);
    $table->timestamp('paid_at')->nullable();
    $table->timestamps();
});

Пять строк миграции, столько же в модели, короткий контроллер, и таблица заказов уже работает: создание, чтение, связи с пользователем, каскадные ограничения внешних ключей. Честное сравнение показывает, что типовое CRUD-приложение на Laravel стартует за две-три недели там, где самописный каркас съедает два-три месяца. Именно это преимущество и становится ловушкой. Удобные инструменты приучают не смотреть в сгенерированный SQL, а нагрузка тем временем растёт незаметно: сначала сотни записей, потом сотни тысяч.

Где скорость сборки превращается в медленные запросы Eloquent через пару лет

Красивый код Eloquent скрывает SQL. Обращение к связанной модели внутри цикла запускает отдельный запрос на каждой итерации, и классическая проблема N+1 набирает сотни запросов на один экран списка. Страница со ста пользователями, у которой выводится число заказов каждого, честно делает 101 запрос к базе вместо двух. Локально с десятью записями никто ничего не замечает, на продакшене с реальным объёмом страница вдруг "внезапно" тормозит, и в чём причина, разработчики ищут часами.

// Медленно: основной запрос плюс запрос на каждую строку
foreach (User::all() as $user) {
    echo $user->orders->count();
}

// Быстро: две выборки вместо ста одной
$users = User::with('orders')->get();

Вторая ловушка роста - контроллеры и модели, которые постепенно обрастают логикой. Модель User на третьем году жизни проекта спокойно разрастается до двух тысяч строк: заказы, подписки, рефералы, скидки, экспорт, интеграции. Никакой фреймворк не спасёт от этого сам по себе, но Laravel делает путь наименьшего сопротивления особенно скользким, потому что всё "и так работает" и refactoring откладывается на следующий спринт.

Реальное лечение N+1 запросов в зрелом Laravel приложении

Первый шаг - отключить ленивые загрузки в дев-окружении, чтобы проблема ловилась на этапе написания кода, а не в продакшене. Eloquent умеет бросать исключение при каждом обращении к незагруженной связи.

// AppServiceProvider.php
public function boot(): void
{
    Model::preventLazyLoading(! $this->app->isProduction());
}

Второй шаг - приучить команду к явным жадным загрузкам и счётчикам. Метод with подставляет связанные данные второй выборкой, withCount считает их агрегатом на стороне базы, вместо того чтобы тянуть целую коллекцию в память ради одного числа. Для условных фильтров по связям существует загрузка с ограничением: User::with(["orders" => fn ($q) => $q->where("status", "paid")]).

Экспорт и пересчёт больших массивов строятся порциями, иначе даже корректный код падает по памяти. Полмиллиона строк через all() просто не влезут в лимиты процесса, а chunkById обрабатывает их пакетами и не пропускает записи при изменении выборки между пакетами, в отличие от offset-пагинации внутри выборки.

Order::where('status', 'paid')->chunkById(500, function (Collection $batch) {
    // обработка порции из 500 строк
});

Третий шаг - постоянное наблюдение за временем запросов. Слушатель DB::listen пишет в лог всё, что дольше сотни миллисекунд, и медленные места перестают быть сюрпризом.

Рядом с ручным слушателем работают инструменты визуального профилирования. Debugbar показывает число запросов и время каждого прямо на странице, Telescope собирает запросы, задания, события и исключения с фильтрами, Clockwork пишет метрики в отдельную панель браузера. Правило зрелых команд простое: пулреквест, который добавляет странице больше десяти запросов, не проходит ревью, и цифра на панели дисциплинирует лучше любых лекций.

DB::listen(function (QueryExecuted $query) {
    if ($query->time > 100) {
        Log::warning('slow query', ['sql' => $query->sql, 'ms' => $query->time]);
    }
});

Кэширование конфигурации и очереди как способ вернуть быстрые ответы сервера

Часть потерь на каждый запрос сидит не в базе, а в самом стеке. PHP заново читает десятки конфигурационных файлов, собирает карту маршрутов и слушателей событий при каждом обращении. Laravel умеет один раз испечь всё это в единый кэш.

php artisan config:cache
php artisan route:cache
php artisan event:cache
php artisan view:cache

Экономия на одном запросе измеряется единицами миллисекунд, но на десятках тысяч запросов в час она складывается в заметные проценты. Сюда же относятся opcache в настройках PHP и composer install с флагом optimize-autoloader на продакшене: без него автозагрузка классов каждый раз сканирует файловую систему.

Главный рычаг скорости ответов всё же другой - очереди. Отправка сотни писем, генерация PDF, пересчёт статистики и обращение к внешнему сервису внутри HTTP-запроса крадут секунды у пользователя. Схема всегда одна: контроллер ставит задачу в очередь и мгновенно отвечает, воркер делает тяжёлую работу в фоне, Horizon показывает глубину очереди, неудачи и повторы.

class RecalculateReport implements ShouldQueue
{
    public int $tries = 3;
    public int $backoff = 30;

    public function __construct(public int $reportId) {}

    public function handle(ReportService $service): void
    {
        $service->recalculate($this->reportId);
    }
}

Поля tries и backoff задают три попытки с нарастающей задержкой, упавшие задачи копятся в таблице failed_jobs, а supervisord или systemd держат нужное число воркеров живыми. Разница между "письмо уходит четыре секунды" и "ответ приходит за 40 миллисекунд" закрывается одной очередью.

Выбор драйвера тоже влияет на скорость. Драйвер database не требует отдельного сервера и честно тянет средние нагрузки, Redis держит десятки тысяч операций в секунду и умеет приоритеты через отдельные списки, а команда queue:work --queue=critical,default выводит срочные задания из-за спины фоновых отчётов. У Horizon поверх Redis появляется дашборд с метриками, тегами заданий и контролем параллельности, и глубина очереди перестаёт быть абстракцией.

Octane и долгоживущие воркеры для случаев когда классического PHP-FPM уже не хватает

Когда конфигурация закэширована, запросы полечены, индексы на месте, а мощности всё равно не хватает, у Laravel есть следующий уровень. Octane поднимает приложение в памяти под Swoole, FrankenPHP или RoadRunner: фреймворк загружается один раз, и каждый воркер обрабатывает тысячи запросов без пересборки приложения. На лёгких страницах прирост измеряется кратно, на тяжёлых запросах к базе он почти незаметен.

У модели долгоживущего процесса есть своя цена: состояние переживает запросы. Статические свойства, синглтоны контейнера и глобальные переменные, безобидные в классическом режиме, под Octane протекают между запросами разных пользователей.

// Опасно: значение переживёт запрос и достанется чужому
class CurrencyRate
{
    public static ?float $rate = null;
}

// Правильно: кэш вместо статического состояния
$rate = Cache::remember('usd_rate', 3600, fn () => $service->fetch());

Octane даёт выигрыш только проектам с аккуратным кодом, на проблемном приложении он просто быстрее повторяет его проблемы. Прыгать на него стоит после того, как opcache, очереди и индексы уже выжаты, иначе вместо ускорения команда получит загадочные баги, которые воспроизводятся через раз.

Профилирование базы данных крупного Laravel проекта и работа с индексами

На зрелом проекте виновник медленных страниц почти всегда база, а не PHP. Отсутствующий индекс на составном фильтре, выборка всех колонок через звёздочку, поиск по подстроке вида %фрагмент% без полнотекстового индекса, пагинация со смещением на миллионной странице - типовая четвёрка причин. Перед добавлением индекса обязателен EXPLAIN или EXPLAIN ANALYZE: индекс, построенный не под реальный запрос, просто занимает место и замедляет запись.

// Составной индекс под частый фильтр отчётов
Schema::table('orders', function (Blueprint $table) {
    $table->index(['status', 'paid_at']);
});

// Курсорная пагинация не деградирует в глубине списка
Order::where('status', 'paid')->orderBy('id')->cursorPaginate(100);

Дальше по классике растущих проектов: реплики чтения снимают нагрузку с мастера, горячие счётчики выносятся в отдельные таблицы и пересчитываются по расписанию, а тяжёлые отчёты читают заранее подготовленные агрегаты. Eloquent поддерживает реплики из коробки через секции read и write в конфиге подключения. Отдельная дисциплина - кэширование результатов: редко меняющиеся блоки живут в Redis или Memcached, а не пересобираются запросами при каждом показе страницы.

Для таблиц, растущих на миллионы строк в год, в ход идёт партиционирование по месяцам: старые партиции читают отчёты, свежие держатся в оперативном кэше базы, а архивирование превращается в отсоединение партиции вместо многочасового удаления строк. Отдельная боль зрелых проектов - конкурентные инкременты: когда тысячу показов в секунду дёргают UPDATE одной и той же строки, база выстраивает очередь блокировок, и счётчик переезжает в кэш с асинхронным сбросом.

Правила дисциплины которые сохраняют скорость Laravel на годы

Опыт команд, чьи Laravel-проекты живут по пять-семь лет без деградации, сводится к короткому списку привычек:

  1. запрет ленивых загрузок Eloquent включён с первого дня через preventLazyLoading;
  2. всё, что длится дольше секунды, уходит в очередь, а не в HTTP-запрос;
  3. на каждое поле частых фильтров и сортировок есть индекс, проверенный через EXPLAIN;
  4. медленные запросы логируются в продакшене и разбираются раз в неделю;
  5. кэш конфигурации и маршрутов пересобирается автоматически на каждом деплое;
  6. списки с глубокими страницами пагинируются по курсору, а не по смещению;
  7. логика из контроллеров переезжает в сервисы и события до того, как контроллеры обрастут сотнями строк.

Ничего экзотического в списке нет, и в этом вся соль: скорость Laravel на пятом году жизни складывается из скучных привычек, а не из героической оптимизации подручными средствами.

Скорость на старте и скорость на пятом году - два разных навыка. Первый Laravel отдаёт сам, второй покупается дисциплиной: жадные загрузки, очереди, индексы, наблюдение за запросами. Фреймворк с возрастом не замедляется, растут объём данных и число связей между сущностями, и цена каждого ленивого запроса становится выше. Команды, включившие preventLazyLoading в первую неделю и отправившие в очередь всё тяжёлое, через годы получают приложение, которое осталось быстрым, а не приложение, которое срочно спасают перед релизом.