Как вытащить сырой SQL из Query Builder в PHP: 3 рабочих метода

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

«Доктор, у меня Query Builder что-то странное генерирует…»

Знакомая ситуация: вы пишете элегантный цепной запрос на Query Builder, уверены, что всё работает идеально, но на выходе — либо ошибка, либо не те данные. Первое, что приходит в голову любому PHP-разработчику: «Покажи мне, что ты там на самом деле генерируешь!».

Вопрос «How do I get the query builder to output its raw SQL query as a string?» — один из самых популярных на Stack Overflow (тысячи просмотров и десятки ответов). И это не случайно. Умение быстро получить чистый SQL — это суперсила, которая экономит часы отладки.

В этой статье мы разберём 3 рабочих метода для PHP-фреймворков (Laravel, Doctrine, CakePHP), покажем реальные примеры кода и объясним, когда какой метод использовать. Поехали!

Почему это вообще нужно? (Кратко и по делу)

  • Отладка — понять, почему запрос возвращает не те данные.
  • Оптимизация — увидеть, какие JOIN'ы и WHERE'ы реально выполняются.
  • Обучение — новички часто не понимают, как Query Builder транслирует методы в SQL.
  • Логирование — сохранить сырой запрос для аудита или реплея.

Готовы? Тогда переходим к коду!

Метод 1: Laravel Eloquent / Query Builder — классика

В Laravel есть несколько способов, но самый надёжный — использовать toSql() вместе с getBindings(). Почему вместе? Потому что toSql() показывает запрос с плейсхолдерами ?, а без подстановки значений это бесполезно.

Простой пример: SELECT с WHERE

use App\Models\User;

$query = User::where('email', 'like', '%@gmail.com')
             ->where('active', 1)
             ->orderBy('created_at', 'desc');

// Получаем SQL с плейсхолдерами
$sql = $query->toSql();
$bindings = $query->getBindings();

// Собираем всё в читаемый вид
$fullSql = vsprintf(str_replace('?', '%s', $sql), $bindings);

echo $fullSql;
// Результат:
// SELECT * FROM users WHERE email LIKE '%@gmail.com' AND active = 1 ORDER BY created_at DESC

Важно: vsprintf — не панацея. Для сложных типов данных (например, NULL или массивы) лучше использовать Str::replaceArray() или пакет laravel-query-builder-debug.

Продвинутый вариант: с join и whereIn

$query = DB::table('orders')
    ->join('users', 'orders.user_id', '=', 'users.id')
    ->whereIn('orders.status', ['pending', 'processing'])
    ->where('orders.total', '>', 100)
    ->select('orders.*', 'users.name');

$bindings = $query->getBindings();
$sql = $query->toSql();

// Красиво форматируем
$fullSql = preg_replace_array('/\?/', $bindings, $sql);
echo $fullSql;
// SELECT orders.*, users.name FROM orders INNER JOIN users ON orders.user_id = users.id WHERE orders.status IN ('pending', 'processing') AND orders.total > 100

Совет: Используйте preg_replace_array() — он безопаснее, чем vsprintf, особенно когда в данных есть символ %.

Метод 2: Doctrine DBAL — для тех, кто любит строгость

Doctrine Query Builder (часто используется в Symfony) — зверь посложнее. Здесь нет готового toSql(), но есть getSQL() и getParameters(). Плюс нужно учитывать типы параметров.

Базовый пример

use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Query\QueryBuilder;

/** @var Connection $connection */
$queryBuilder = $connection->createQueryBuilder();
$queryBuilder
    ->select('p.*', 'c.name as category_name')
    ->from('products', 'p')
    ->join('p', 'categories', 'c', 'p.category_id = c.id')
    ->where('p.price > :minPrice')
    ->andWhere('p.stock > 0')
    ->setParameter('minPrice', 50);

$sql = $queryBuilder->getSQL();
$params = $queryBuilder->getParameters();
$types = $queryBuilder->getParameterTypes();

echo "SQL: " . $sql . PHP_EOL;
// SQL: SELECT p.*, c.name as category_name FROM products p INNER JOIN categories c ON p.category_id = c.id WHERE p.price > :minPrice AND p.stock > 0

echo "Params: " . json_encode($params) . PHP_EOL;
// Params: {"minPrice":50}

// Полностью подставленный запрос (имитация)
$fullSql = $sql;
foreach ($params as $key => $value) {
    $fullSql = str_replace(':' . $key, is_string($value) ? "'$value'" : $value, $fullSql);
}
echo "Full SQL: " . $fullSql;
// Full SQL: SELECT p.*, c.name as category_name FROM products p INNER JOIN categories c ON p.category_id = c.id WHERE p.price > 50 AND p.stock > 0

Нюанс: Doctrine не умеет сам подставлять параметры в строку — это сделано намеренно, чтобы избежать SQL-инъекций. Поэтому ручная подстановка — только для отладки, не для продакшена!

Метод 3: Универсальный — слушаем события (для любой ORM)

Если вам нужно логировать все запросы, а не один конкретный, лучший способ — подписаться на события выполнения запросов. Это сработает и для Laravel, и для Doctrine, и для Propel.

Laravel: глобальный слушатель

// В AppServiceProvider::boot()
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

DB::listen(function ($query) {
    $sql = $query->sql;
    $bindings = $query->bindings;
    $time = $query->time;

    // Формируем полный запрос
    $fullSql = preg_replace_array('/\?/', $bindings, $sql);

    Log::channel('sql')->info('Query executed', [
        'sql' => $fullSql,
        'time' => $time . ' ms',
    ]);
});

Плюс: Вы видите все запросы, включая те, что делает Eloquent при загрузке отношений. Минус: На продакшене лучше не включать — убьёт производительность.

Doctrine: профайлер в Symfony

// В config/packages/doctrine.yaml
doctrine:
    dbal:
        logging: true
        profiling: true

// В контроллере или сервисе
/** @var \Doctrine\DBAL\Logging\DebugStack $logger */
$logger = $this->get('doctrine.dbal.logger.chain.default');
$queries = $logger->queries;

foreach ($queries as $query) {
    echo $query                

Блоги

Рекомендаций по книгам