Перейти к основному содержимому

5.07. Работа с БД

Разработчику Архитектору

Работа с БД

PHP изначально создавался как язык сценариев для генерации динамических веб-страниц на основе данных, и с самого начала его развития поддержка работы с внешними источниками данных — в первую очередь, с реляционными базами данных — являлась одним из ключевых приоритетов. Сегодня, несмотря на эволюцию языка и появление альтернативных подходов (ORM, GraphQL-интерфейсы, NoSQL), работа с базами данных остаётся фундаментальной составляющей большинства PHP-приложений. Понимание того, как PHP взаимодействует с данными — от низкоуровневого чтения файлов до абстрактных драйверов подключения к СУБД — необходимо для грамотного проектирования, отладки и оптимизации.

В данном разделе последовательно рассматриваются три взаимосвязанных аспекта:

  1. Общие принципы работы PHP с данными: жизненный цикл скрипта, модель памяти, передача данных между этапами обработки запроса.
  2. Работа PHP с файловой системой как с первичным и вспомогательным хранилищем: чтение, запись, блокировки, временные файлы.
  3. Подключение к базам данных: эволюция интерфейсов, архитектура драйверов, выбор расширений, безопасность и управление соединениями.

Раздел не ограничивается простым перечислением функций — акцент сделан на объяснении почему реализовано именно так, какие компромиссы были приняты, и как это влияет на поведение приложений в реальных условиях.


Как PHP работает с данными

PHP — интерпретируемый язык сценариев, выполняемый в контексте веб-сервера (например, Apache или Nginx через модуль или FastCGI-процесс) либо в CLI-режиме. Каждый HTTP-запрос порождает отдельный процесс или поток выполнения скрипта, изолированный от других запросов. Это фундаментальное свойство определяет модель работы с данными.

В пределах одного запроса PHP-скрипт последовательно проходит стадии: загрузка, инициализация, выполнение, завершение. Все данные, созданные в ходе выполнения — переменные, массивы, объекты — существуют только в памяти процесса и уничтожаются при завершении скрипта (кроме случаев явного сохранения в долговременные хранилища). Таким образом, PHP не поддерживает встроенное состояние между запросами. Любая персистентность (сохранение данных во времени) достигается через внешние механизмы: файлы, базы данных, кэш-системы (Memcached, Redis), сессии (которые, в свою очередь, часто хранятся в файлах или БД).

Модель памяти PHP — копирование при записи (copy-on-write), оптимизированная для работы с хэш-таблицами (внутренняя структура zval). Это позволяет эффективно обрабатывать большие объёмы структурированных данных (например, результаты выборок из БД), но накладывает ограничения на объём оперативной памяти, доступной одному процессу. При работе с большими наборами данных (например, экспорт таблицы в CSV) рекомендуется использовать потоковую обработку — чтение и запись порциями, без загрузки всего объёма в массив.

PHP предоставляет единый API для работы с данными независимо от их источника: будь то локальный файл, входной поток (php://input), переменные $_POST, $_GET, $_COOKIE, или результат SQL-запроса — все они преобразуются в внутренние типы PHP (строки, числа, массивы, объекты). Конвертация типов происходит автоматически, но не всегда однозначно: например, строка "0" приводится к логическому false, что может вызывать ошибки при валидации. Поэтому при работе с данными из внешних источников критически важна явная валидация и приведение типов.

Потоки ввода-вывода в PHP реализованы через обёртки (stream wrappers) — единый интерфейс, позволяющий читать и писать в файлы и в сетевые сокеты, сжатые архивы (compress.zlib://), временные буферы (php://temp), и даже пользовательские источники. Эта абстракция лежит в основе многих механизмов, включая загрузку файлов, обработку API-ответов и, косвенно, работу с БД через текстовые протоколы.


Как PHP работает с файлами

Файловая система — один из самых ранних и надёжных способов хранения данных в веб-приложениях. PHP предоставляет два уровня API для работы с файлами: низкоуровневый (на основе C-подобных функций fopen, fread, fwrite, fclose) и высокоуровневый (например, file_get_contents, file_put_contents). Выбор уровня зависит от задачи: для простых операций чтения/записи целиком предпочтителен высокоуровневый API, для потоковой обработки, случайного доступа или работы с блокировками — низкоуровневый.

Важнейшее понятие — ресурс (resource). При открытии файла функцией fopen() возвращается дескриптор ресурса — ссылка на открытый файловый дескриптор в ядре ОС. Этот ресурс автоматически освобождается при завершении скрипта, но явное закрытие через fclose() рекомендуется: это освобождает системные ресурсы раньше, предотвращает превышение лимита открытых файлов и позволяет корректно сбросить буферы.

PHP поддерживает работу с блокировками файлов через flock(). Это механизм координации доступа нескольких процессов к одному файлу. Например, при записи в лог-файл из нескольких параллельных запросов без блокировки возможна перемешка строк. Рекомендуемый паттерн: открыть файл в режиме 'a' (добавление), выполнить flock($handle, LOCK_EX), записать данные, сбросить буфер fflush(), снять блокировку flock($handle, LOCK_UN), закрыть файл. Следует учитывать, что flock() реализует консультативную блокировку — она работает только если все процессы используют её явно; в некоторых файловых системах (например, NFS) поведение может отличаться.

Файлы также используются для реализации сессий (session.save_handler = files по умолчанию): данные сессии сериализуются и сохраняются в файл с именем вида sess_<session_id>. Это простое, но масштабируемое решение, однако при высокой нагрузке и распределённой архитектуре предпочтительны альтернативы — хранение в Redis или базе данных.

PHP корректно обрабатывает различия в именах файлов и путях между операционными системами. Константа DIRECTORY_SEPARATOR позволяет писать кроссплатформенный код. Однако при работе с именами файлов, поступающими от пользователя (например, при загрузке), требуется строгая валидация: проверка на попытку выхода за пределы разрешённой директории (directory traversal), нормализация пути (realpath()), фильтрация недопустимых символов.

Временные файлы создаются автоматически при загрузке ($_FILES['file']['tmp_name']) или вручную через tmpfile() либо tempnam(). Они удаляются при завершении скрипта, но при длительных операциях (например, конвертация изображения) лучше явно удалять их после использования, чтобы избежать накопления мусора в /tmp.


Как PHP подключается к базам данных и что использует

Подключение PHP к базе данных — это взаимодействие через многоуровневую стек-архитектуру, состоящую из трёх ключевых компонентов:

  1. Расширение уровня языка — код, написанный на C, скомпилированный в модуль PHP (.so или .dll), предоставляющий функции и классы в пользовательском пространстве.
  2. Драйвер клиентской библиотеки СУБД — бинарная библиотека (например, libmysqlclient, libpq, oci8), реализующая протокол общения с конкретной СУБД.
  3. Сервер СУБД — отдельный процесс (например, mysqld, postgres), управляющий данными и обрабатывающий запросы.

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

На сегодняшний день существует три основных способа подключения к БД в PHP, отражающих историческую эволюцию языка:

1. Устаревшие расширения (mysql_*, mssql_*, mysql_*)

Функции вида mysql_connect(), mysql_query() были первыми и долгое время доминировали. Они предоставляли «тонкий» слой над клиентской библиотекой MySQL. Однако они имели фатальные недостатки: отсутствие поддержки подготовленных выражений (prepared statements), что делало приложения уязвимыми к SQL-инъекциям; отсутствие ООП-интерфейса; зависимость от устаревшей версии libmysqlclient; отсутствие поддержки новых функций MySQL (например, multiple statements). Все такие расширения официально удалены из PHP, начиная с версии 7.0. Их использование в новых проектах недопустимо.

2. PDO (PHP Data Objects)

PDO — это абстрактный интерфейс доступа к базам данных, введённый в PHP 5.1. Он представляет собой прослойку, унифицирующую работу с разными СУБД через единый API. PDO поддерживает драйверы для MySQL, PostgreSQL, SQLite, Oracle, MS SQL Server и многих других (всего более 12 встроенных, плюс пользовательские).

Ключевые особенности PDO:

  • Единый набор классов (PDO, PDOStatement) и методов для всех СУБД.
  • Обязательная поддержка подготовленных выражений через prepare()execute(). Это основной механизм защиты от SQL-инъекций: параметры передаются отдельно от текста запроса и обрабатываются на уровне СУБД как данные.
  • Возможность эмуляции подготовленных выражений (по умолчанию включена для MySQL, отключена для PostgreSQL), что полезно при работе со старыми серверами, но требует дополнительной осторожности с типами.
  • Гибкая настройка поведения через атрибуты: режим ошибок (PDO::ERRMODE_EXCEPTION — рекомендуется), режим автофетча, буферизация результатов и др.
  • Поддержка транзакций, savepoints, многократного выполнения запросов с разными параметрами.

PDO не скрывает различий в синтаксисе SQL между СУБД — запросы всё равно должны быть совместимы с целевой системой.

3. Расширения, ориентированные на конкретную СУБД: mysqli и pgsql

Эти расширения предоставляют «нативный» доступ к MySQL и PostgreSQL соответственно. Они тесно интегрированы с клиентскими библиотеками (libmysqlclient / mysqlnd для mysqli, libpq для pgsql) и дают доступ ко всем возможностям этих СУБД.

  • mysqli (MySQL Improved) — прямая замена mysql_*, появившаяся в PHP 5.0. Поддерживает как процедурный, так и объектно-ориентированный интерфейс. Обязательно включает подготовленные выражения (prepare()bind_param()execute()), асинхронные запросы, множественные запросы, серверные хранимые процедуры, потоковую передачу больших объектов (LOB). Использует mysqlnd (MySQL Native Driver) по умолчанию — реализацию клиента на чистом C без зависимости от внешней libmysqlclient, что улучшает производительность и совместимость.

  • pgsql — расширение для PostgreSQL, предоставляющее функции вида pg_connect(), pg_query_params(). Поддерживает асинхронные операции, двоичный протокол передачи, массивы, JSON-типы, NOTIFY/LISTEN. Для подготовленных выражений использует pg_prepare() / pg_execute() или pg_query_params() (более простой способ передачи параметров).

Выбор между PDO и нативными расширениями — вопрос архитектурной стратегии:

  • Если приложение изначально проектируется под одну СУБД и требует максимального контроля над её возможностями (например, использование PostgreSQL-специфичных типов, геоданных PostGIS), предпочтителен pgsql или mysqli.
  • Если важна переносимость (например, поддержка нескольких СУБД в одном продукте, или будущая миграция), или разработка ведётся в команде с едиными стандартами — выбирается PDO.

Независимо от выбранного интерфейса, критически важно:

  • Использовать только подготовленные выражения или строго экранировать входные данные (через mysqli_real_escape_string() или pg_escape_literal() — но это менее надёжно).
  • Управлять соединениями: избегать открытия нового соединения на каждый запрос (дорого по ресурсам), использовать пулы соединений, где возможно (например, через pdo_mysql с mysqlnd и настройкой mysqlnd.collect_statistics), корректно закрывать соединения при завершении.
  • Обрабатывать ошибки: проверять результаты prepare(), execute(), использовать исключения (PDO::ERRMODE_EXCEPTION), логировать сбои.
  • Не хранить учётные данные в коде — использовать переменные окружения или конфигурационные файлы вне корня веб-доступа.

Современные фреймворки (Laravel, Symfony, Yii) инкапсулируют работу с БД через абстракции более высокого уровня (Query Builder, ORM), но всё равно опираются на один из трёх указанных механизмов на нижнем уровне. Понимание базового уровня позволяет эффективно отлаживать проблемы, возникающие при их использовании.


Безопасность взаимодействия с базой данных

PHP-скрипт получает входные данные из недоверенного источника (браузер, API-вызов, файл), обрабатывает их и передаёт в доверенную, но чувствительную к синтаксису систему — СУБД. Критическая уязвимость возникает, когда данные интерпретируются как часть исполняемой команды, а не как её параметр.

SQL-инъекции

Многие разработчики полагают, что вызов mysqli_real_escape_string() или pg_escape_literal() делает запрос безопасным. Это верно только при соблюдении всех условий одновременно:

  • Используется правильная кодировка соединения (например, utf8mb4 для MySQL, а не latin1);
  • Экранирование применяется ко всем пользовательским данным без исключения;
  • Данные используются исключительно внутри строковых литералов (в кавычках), а не в именах таблиц, столбцов, операторах ORDER BY, LIMIT, или частях DDL-запросов;
  • Нет двойного экранирования (например, при повторной обработке уже экранированной строки).

На практике эти условия часто нарушаются. Например, параметр сортировки ($_GET['sort']) часто подставляется напрямую в ORDER BY, где экранирование не работает — только белый список допустимых значений. Или приложение принимает имя таблицы из URL для генерации отчёта — в этом случае безопасной альтернативой является хранение маппинга «псевдоним → имя таблицы» в коде.

Подготовленные выражения (prepared statements) устраняют эти риски архитектурно: этап анализа и компиляции запроса (prepare()) происходит отдельно от этапа передачи данных (execute()). Параметры передаются в двоичном виде по отдельному каналу протокола, и СУБД интерпретирует их строго как данные, вне зависимости от содержимого. Это работает даже с управляющими символами, нулевыми байтами, многобайтовыми последовательностями.

Важно понимать: подготовленные выражения защищают только параметры, но не структуру запроса. Даже при их использовании нельзя динамически формировать имя таблицы или список полей из пользовательского ввода без дополнительной валидации. Безопасная динамика достигается через строго контролируемые шаблоны: например, конструктор запросов (Query Builder), который разрешает подстановку имён только из заранее определённого списка колонок.

Безопасность соединения

Учётные данные для подключения к БД (хост, порт, имя пользователя, пароль) никогда не должны храниться в коде или в файлах внутри веб-корня (например, public/ или htdocs/). Рекомендуемый подход:

  • Хранить параметры в переменных окружения (.env файл при локальной разработке, putenv() или getenv() — но только если окружение надёжно изолировано; в production — через системный менеджер окружения: systemd, Docker secrets, облачные vault’ы).
  • Использовать принцип минимальных привилегий: учётная запись приложения должна иметь только необходимые права (SELECT, INSERT, UPDATE на конкретные таблицы, без DROP, ALTER, GRANT, FILE).
  • Отключать ненужные функции на уровне СУБД: например, в MySQL — local_infile=0, чтобы предотвратить чтение локальных файлов через LOAD DATA INFILE.

Для дополнительной защиты можно использовать SSL/TLS-шифрование соединения с БД. В PDO это делается через атрибуты:
PDO::MYSQL_ATTR_SSL_CA, PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT.
В mysqli — через mysqli_ssl_set(). Это особенно важно при подключении к удалённой БД через публичную сеть.


Управление соединениями

Каждое соединение с СУБД — это тяжёлый системный ресурс: выделение памяти на сервере, поток обработки, сетевой сокет. Частое открытие и закрытие соединений в пределах одного запроса (например, при каждом обращении к разным моделям) приводит к значительным накладным расходам — до 70 % времени выполнения может уходить на handshake.

Жизненный цикл соединения в PHP

В модели «один запрос — один процесс» соединение с БД по умолчанию живёт всю длительность выполнения скрипта. Явный вызов $pdo = null или $mysqli->close() освобождает ресурс раньше, но в большинстве случаев это избыточно — PHP делает это автоматически при завершении. Исключение — длительные CLI-скрипты, где соединение может простаивать часами: там явное закрытие оправдано.

Однако автоматическое закрытие не означает, что соединение уничтожается на стороне сервера. Многие СУБД (включая MySQL и PostgreSQL) поддерживают механизм персистентных соединений (persistent connections). При их включении соединение не закрывается после завершения скрипта, а возвращается в пул, откуда его может взять следующий процесс. Это экономит время на handshake.

В PDO персистентность включается через опцию PDO::ATTR_PERSISTENT => true в DSN или атрибутах. В mysqli — через префикс p: в хосте: new mysqli('p:localhost', ...).

Но персистентные соединения имеют существенные ограничения:

  • Состояние соединения (например, SET NAMES, SET time_zone, временные таблицы, переменные сессии) сохраняется между запросами — это может привести к неожиданному поведению.
  • Транзакции должны быть явно завершены (COMMIT или ROLLBACK) перед завершением скрипта, иначе следующий запрос унаследует незавершённую транзакцию.
  • При высокой нагрузке пул может иссякнуть, если не настроены лимиты (max_connections в MySQL).
  • Не все драйверы корректно поддерживают персистентность (например, при использовании mysqlnd с определёнными настройками).

Поэтому в веб-приложениях персистентные соединения используются редко. Предпочтительнее — одно соединение на запрос, инициализируемое в начале обработчика и уничтожаемое в конце. Это проще, предсказуемее и безопаснее.

Соединения в асинхронных и долгоживущих процессах

В контексте современных решений (например, RoadRunner, Swoole, ReactPHP), где PHP-процесс живёт долго и обслуживает множество запросов, управление соединениями требует особого подхода:

  • Соединения нельзя хранить в глобальных переменных или статических свойствах, так как они станут разделяемыми между запросами.
  • Требуется явный пул соединений с проверкой «свежести» перед использованием (например, выполнение SELECT 1 для проверки активности).
  • Необходима обработка разрывов соединения («gone away») — переподключение и повтор запроса при необходимости.

Эти сценарии выходят за рамки классического PHP-FPM, но всё чаще встречаются в высоконагруженных системах.


Транзакции

Транзакция — это последовательность операций, выполняемых как единое целое: либо все изменения фиксируются, либо ни одно. PHP предоставляет простой, но мощный API для управления транзакциями, однако его правильное использование требует понимания характеристик СУБД.

Базовый API

В PDO:

$pdo->beginTransaction();
try {
$pdo->exec("UPDATE accounts SET balance = balance - 100 WHERE id = 1");
$pdo->exec("UPDATE accounts SET balance = balance + 100 WHERE id = 2");
$pdo->commit();
} catch (Exception $e) {
$pdo->rollBack();
throw $e;
}

В mysqli:

$mysqli->autocommit(false);
try {
$mysqli->query("UPDATE accounts SET balance = balance - 100 WHERE id = 1");
$mysqli->query("UPDATE accounts SET balance = balance + 100 WHERE id = 2");
$mysqli->commit();
} catch (Exception $e) {
$mysqli->rollback();
throw $e;
}

Важно: autocommit по умолчанию включён в MySQL и PostgreSQL. Каждый INSERT, UPDATE, DELETE выполняется в своей неявной транзакции. Только при явном BEGIN (или START TRANSACTION) начинается многооператорная транзакция.

Уровни изоляции и их влияние

Уровень изоляции определяет, как транзакции «видят» изменения друг друга. PHP позволяет задать его через SQL-команду:

SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;

или через атрибуты PDO (для MySQL):

$pdo->exec("SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE");

Основные уровни:

  • READ UNCOMMITTED — возможны «грязные» чтения (чтение незафиксированных данных). Редко используется.
  • READ COMMITTED — видны только зафиксированные изменения. Наиболее распространённый уровень в PostgreSQL по умолчанию.
  • REPEATABLE READ — гарантирует, что повторное чтение тех же строк вернёт те же данные, даже если другие транзакции их изменили. Уровень по умолчанию в InnoDB (MySQL).
  • SERIALIZABLE — полная изоляция, эмуляция последовательного выполнения. Самый строгий, но с наибольшими накладными расходами.

Повышение уровня изоляции увеличивает вероятность блокировок и deadlock’ов. Например, при REPEATABLE READ в InnoDB запрос SELECT ... FOR UPDATE может заблокировать существующие строки и «дыры» между ними (gap locks), чтобы предотвратить фантомные чтения.

Savepoints

Savepoint позволяет установить точку внутри транзакции, к которой можно откатиться, не теряя всех предыдущих изменений:

$pdo->beginTransaction();
$pdo->exec("INSERT INTO logs (msg) VALUES ('step 1')");
$pdo->exec("SAVEPOINT after_step1");
try {
$pdo->exec("INSERT INTO logs (msg) VALUES ('step 2')");
$pdo->exec("INSERT INTO accounts (id) VALUES (NULL)"); // ошибка: нарушение NOT NULL
} catch (PDOException $e) {
$pdo->exec("ROLLBACK TO SAVEPOINT after_step1");
// лог 'step 1' останется, 'step 2' — откатится
}
$pdo->commit();

Это особенно полезно при выполнении нескольких независимых операций в рамках одного бизнес-процесса.


Обработка ошибок

По умолчанию многие функции mysqli и pgsql возвращают false при ошибке, не вызывая исключений. Это приводит к «тихим» сбоям: скрипт продолжает выполнение с некорректными данными.

Рекомендуемый режим:

  • Для PDO:
    $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
  • Для mysqli:
    mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); — включает выброс исключений mysqli_sql_exception.

Такой подход заставляет разработчика явно обрабатывать ошибки через try/catch, что повышает надёжность. В блоке catch можно:

  • Залогировать детали (код ошибки, SQLSTATE, текст запроса — без параметров!),
  • Вернуть понятное сообщение пользователю (без раскрытия внутренней структуры БД),
  • Повторить операцию (например, при временной сетевой ошибке),
  • Инициировать graceful degradation.

Коды ошибок СУБД стандартизированы частично: SQLSTATE (пять символов, например, 23000 — нарушение ограничения уникальности) поддерживается в PDO и многих драйверах. Конкретные коды (например, 1062 для MySQL, 23505 для PostgreSQL) зависят от СУБД и требуют отдельной обработки при необходимости различать типы ошибок.


Производительность

Производительность взаимодействия PHP с БД определяется тремя уровнями: сетевым, серверным (СУБД) и клиентским (PHP-драйвер). Оптимизация должна начинаться с измерения — без профилирования любые правки носят предположительный характер.

Инструменты профилирования

  • На уровне СУБД:

    • В MySQL: slow query log (лог медленных запросов), EXPLAIN / EXPLAIN ANALYZE (план выполнения), performance_schema (история запросов, блокировки).
    • В PostgreSQL: log_min_duration_statement, EXPLAIN (ANALYZE, BUFFERS), pg_stat_statements (расширение для учёта статистики по запросам).
  • На уровне PHP:

    • Встроенные средства: microtime(true) вокруг выполнения запроса (но без учёта сетевых задержек).
    • Расширение XHProf / Tideways / Blackfire — профилирование CPU, памяти, вызовов функций, включая работу с БД.
    • В PDO: можно логировать все запросы через пользовательский класс, реализующий __call() и оборачивающий prepare()/execute().

Ключевой показатель — время выполнения запроса на стороне СУБД (Query_time в MySQL slow log). Если Query_time велик — проблема в индексах, структуре запроса или статистике. Если разница между Query_time и общим временем велика — проблема в сети, в десериализации данных или в обработке результата в PHP.

Индексы

Индекс — структура данных (чаще всего B+ дерево), позволяющая быстро находить строки по значению столбца. Но он не ускоряет все запросы:

  • Индекс эффективен при фильтрации (WHERE), сортировке (ORDER BY), соединениях (JOIN), но только если условие использует префикс индекса (для составных индексов).
  • Индекс не используется, если:
    • Применяется функция к колонке (WHERE YEAR(created_at) = 2025);
    • Используется LIKE с ведущим шаблоном (LIKE '%text');
    • Тип данных не совпадает (например, строка '123' ищется по числовому индексу);
    • Выборка охватывает большую часть таблицы (оптимизатор может предпочесть full scan).

Составные индексы требуют знания порядка: (status, created_at) ускорит WHERE status = 1 ORDER BY created_at, но не WHERE created_at > '2025-01-01' ORDER BY status. Порядок колонок должен соответствовать селективности: сначала — наиболее различимые значения.

Не стоит создавать индексы «на всякий случай». Каждый индекс замедляет вставку и обновление, увеличивает объём данных на диске, и потребляет память при построении плана запроса. Оптимальный индекс — тот, который покрывает конкретный частый запрос.

Проблема N+1 и её корректное решение

N+1 — классическая ошибка: один запрос получает список записей (например, 100 постов), затем для каждой выполняется отдельный запрос (например, автор поста). Итого — 101 запрос.

Поверхностное решение — «загрузить всё заранее». Но это опасно:

  • Может привести к избыточной выборке («JOIN’им всё подряд»);
  • Ломает пагинацию;
  • Увеличивает потребление памяти при большом N.

Правильный подход — явное управление eager loading’ом через:

  • JOIN с агрегацией (например, GROUP_CONCAT в MySQL, array_agg в PostgreSQL) и пост-обработку в PHP;
  • Отдельный запрос с WHERE id IN (...) — эффективен, если ID заранее известны;
  • Использование ORM-паттернов вроде with() в Eloquent, но с пониманием, как они реализованы.

Важно: даже при использовании JOIN следует избегать cartesian product’ов — например, при соединении один-ко-многим и многие-ко-многим одновременно. В таких случаях предпочтительна двухэтапная загрузка.


Работа с большими объёмами данных

PHP-скрипты ограничены по памяти (memory_limit, по умолчанию 128 МБ, часто 256 МБ в production). Загрузка 100 000 строк в массив через fetchAll() быстро исчерпает лимит. Решение — итеративная, потоковая обработка.

Как драйверы получают данные от СУБД

Существует два режима получения результата:

  1. Буферизованный (buffered): драйвер получает все данные сразу и сохраняет их в памяти PHP. Это позволяет использовать rowCount(), делать повторные fetch() без повторного запроса. По умолчанию используется в PDO::query(), mysqli::query().
  2. Небуферизованный (unbuffered): данные передаются по мере чтения, строка за строкой. Потребление памяти минимально, но соединение с СУБД занято до конца чтения, и нельзя выполнять другие запросы в это же соединение.

В PDO небуферизованный режим включается так:

$stmt = $pdo->prepare("SELECT * FROM huge_table");
$stmt->execute();
$stmt->setFetchMode(PDO::FETCH_ASSOC);
while ($row = $stmt->fetch()) {
// обработка одной строки
}

Но это всё ещё буферизовано по умолчанию в драйвере pdo_mysql! Чтобы отключить буферизацию на уровне драйвера, нужно:

$pdo->setAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, false);

Или использовать PDO::MYSQL_ATTR_CURSOR => PDO::CURSOR_SCROLL с осторожностью.

В mysqli небуферизованный результат возвращается функцией mysqli_use_result() вместо mysqli_store_result() (которую вызывает query() по умолчанию):

$result = $mysqli->query("SELECT * FROM huge_table", MYSQLI_USE_RESULT);
while ($row = $result->fetch_assoc()) {
// ...
}
$result->close(); // важно: освобождает соединение

Потоковый экспорт: CSV, JSON, XML

При генерации отчётов (например, экспорт таблицы в CSV) следует:

  • Отключать буферизацию вывода: ob_end_clean() перед началом;
  • Устанавливать заголовки Content-Type: text/csv, Content-Disposition: attachment;
  • Писать в php://output постранично:
    header('Content-Type: text/csv');
    $output = fopen('php://output', 'w');
    fputcsv($output, ['id', 'name', 'email']);
    $stmt = $pdo->query("SELECT id, name, email FROM users");
    while ($row = $stmt->fetch(PDO::FETCH_NUM)) {
    fputcsv($output, $row);
    // сброс буфера каждые 100 строк, чтобы не накапливать в памяти
    if ($stmt->rowCount() % 100 === 0) {
    fflush($output);
    }
    }
    fclose($output);
  • Избегать fetchAll() и json_encode() больших массивов — для JSON можно использовать yield и построчный вывод через json_encode() одной строки + запятая/скобки, но это требует аккуратного управления синтаксисом.

Пакетная вставка и обновление

Массовая вставка через цикл INSERT в 10 000 итераций — антипаттерн. Даже с подготовленными выражениями накладные расходы на round-trip велики.

Оптимальные методы:

  • Множественный INSERT:

    INSERT INTO t (a, b) VALUES (1,2), (3,4), (5,6);

    Ограничение: длина запроса (параметр max_allowed_packet в MySQL). Обычно безопасно до 1 МБ текста.

  • LOAD DATA INFILE (MySQL) / COPY (PostgreSQL): Запись данных во временный файл (например, в /tmp/export.csv), затем выполнение:

    LOAD DATA INFILE '/tmp/export.csv' INTO TABLE t FIELDS TERMINATED BY ',';

    Требует прав FILE и настройки secure_file_priv, но даёт прирост в 10–100 раз.

  • Транзакционная пакетная вставка: Группировать 100–1000 записей в одной транзакции:

    $pdo->beginTransaction();
    $stmt = $pdo->prepare("INSERT INTO t (a,b) VALUES (?,?)");
    foreach ($data as $i => $row) {
    $stmt->execute($row);
    if ($i % 500 === 0) {
    $pdo->commit();
    $pdo->beginTransaction();
    }
    }
    $pdo->commit();

    Это снижает количество fsync’ов и уменьшает размер лога транзакций.


Работа с NoSQL из PHP

Реляционные и документоориентированные СУБД требуют разных подходов к проектированию. PHP поддерживает их через специализированные расширения.

MongoDB: документы и агрегации

Расширение mongodb (PECL) предоставляет драйвер, совместимый с MongoDB 3.6+. Ключевые особенности:

  • Работа с BSON (Binary JSON): PHP-массивы автоматически преобразуются в документы.
  • Поддержка bulk-операций: insertMany(), updateMany() — эффективны для массовых изменений.
  • Агрегация через pipeline: сложные преобразования выполняются на стороне БД, что критично для больших коллекций.
  • Индексы по вложенным полям: { "user.email": 1 }.

Пример потоковой обработки:

$cursor = $collection->find([], ['batchSize' => 100]);
foreach ($cursor as $document) {
// обработка одного документа
// курсор автоматически подгружает партии по 100
}

Важно: find() возвращает MongoDB\Driver\Cursor, который не загружает всё в память. Это аналог небуферизованного результата в SQL.

Redis: как кэш и как очередь

Расширение redis (PhpRedis) или библиотека predis (pure PHP) позволяют работать с Redis как с:

  • Кэшем: GET/SET с TTL;
  • Очередью: LPUSH/BRPOP;
  • Хранилищем сессий: через session.save_handler = redis.

Особенность — отсутствие встроенной транзакционности «как в SQL». Redis поддерживает MULTI/EXEC, но это pipeline, а не ACID-транзакция: если команда внутри EXEC завершится ошибкой, остальные всё равно выполнятся (в отличие от ROLLBACK).

Общая рекомендация

NoSQL не заменяет SQL — он решает другие задачи. Документные БД эффективны при:

  • Частом изменении структуры данных;
  • Вложенных иерархиях;
  • Горизонтальном масштабировании записи.

Но они не обеспечивают:

  • JOIN’ы (приходится денормализовывать);
  • Строгие ограничения целостности;
  • Гарантированные отчёты в реальном времени.

Гибридные архитектуры (SQL для транзакций, NoSQL для аналитики/кэша) — распространённая и обоснованная практика.


ORM в PHP

ORM — это программная прослойка, задача которой — отобразить реляционные структуры (таблицы, строки, связи) в объектную модель (классы, экземпляры, ассоциации). В PHP ORM не является частью ядра — все решения реализованы на уровне пользовательского кода или через сторонние библиотеки. Это принципиально отличает PHP от языков вроде Java (JPA/Hibernate) или C# (.NET Entity Framework), где ORM глубоко интегрирована в платформу.

Основные реализации в экосистеме PHP

  1. Doctrine ORM
    Наиболее зрелая и гибкая ORM, построенная по принципам Data Mapper и Unit of Work. Опирается на Doctrine DBAL (Database Abstraction Layer) как на уровень доступа к данным.
    Особенности:

    • Явное разделение между сущностью (Entity — объект домена) и репозиторием (Repository — точка доступа к данным).
    • Поддержка метаданных через аннотации, XML, YAML или атрибуты (с PHP 8.0).
    • Lazy loading связей (прокси-объекты через Doctrine\Common\Proxy).
    • Поддержка наследования (single table, joined, class table).
    • Встроенный Query Builder и DQL (Doctrine Query Language) — объектно-ориентированный диалект SQL.
  2. Eloquent ORM (часть фреймворка Laravel)
    Реализует паттерн Active Record: сущность сама управляет своим сохранением ($user->save()), содержит логику доступа к данным.
    Особенности:

    • Простота начала: минимальная конфигурация, соглашения по именованию (таблица users → модель User).
    • Глубокая интеграция с Laravel: события (creating, saved), наблюдатели (Observers), mutators/accessors.
    • Query Builder как основа: User::where('active', 1)->with('posts')->get().
    • Поддержка глобальных областей (global scopes), локальных областей (local scopes).
  3. Propel
    Более ранняя ORM (появилась до Doctrine), также использует Data Mapper. Отличается генерацией классов на основе XML-схемы. Сегодня используется редко, но остаётся в legacy-проектах.

  4. Cycle ORM
    Современная ORM, ориентированная на производительность и гибкость. Поддерживает аннотации, атрибуты, и кодогенерацию. Использует DataMapper и поддерживает lazy/eager/hydration loading. Интегрируется с фреймворками через PSR-стандарты.

Принципы работы

Независимо от реализации, ORM опирается на один из трёх базовых интерфейсов PHP: PDO, mysqli или DBAL (в случае Doctrine). Это означает, что все ограничения и возможности нижележащего драйвера сохраняются. ORM не отменяет необходимость понимания SQL — она лишь изменяет уровень абстракции.

Ключевые механизмы:

  • Гидрация (hydration) — процесс преобразования строк результата запроса в объекты. ORM строит план гидрации на основе метаданных: какие поля идут в какие свойства, как обрабатываются связи. При этом:

    • Поля, не указанные в SELECT, остаются неинициализированными (в Doctrine — null, в Eloquent — отсутствуют в массиве $attributes).
    • Избыточная гидрация (например, SELECT * при использовании только двух полей) ведёт к неоправданным затратам памяти и CPU.
  • Unit of Work (паттерн) — центральный механизм Doctrine. Отслеживает изменения в отслеживаемых объектах (managed entities) и при вызове flush() генерирует минимально необходимый набор SQL-запросов:

    • Новые объекты → INSERT;
    • Изменённые → UPDATE только по тем полям, которые реально изменились (через tracking изменений);
    • Удалённые → DELETE.

    Это позволяет избежать ручного управления транзакциями на уровне бизнес-логики, но требует понимания границ жизненного цикла Unit of Work (обычно — один HTTP-запрос).

  • Lazy Loading — отложенная загрузка связанных сущностей. При обращении к свойству $user->posts ORM автоматически выполняет SELECT * FROM posts WHERE user_id = ?.
    Издержки:

    • Возможность N+1 даже при использовании ORM (если не применить join() или with());
    • Неявные запросы внутри шаблонизатора — трудноотлавливаемые узкие места;
    • Невозможность использования lazy loading вне активного соединения с БД (например, в CLI-задачах после закрытия соединения).

    Решение — явное указание eager loading (->with('posts') в Eloquent, ->join('posts') в Doctrine) и отключение lazy loading в production (fetch: EAGER в Doctrine, ->withoutLazyLoading() в Laravel 9+).

  • Прокси-объекты (Doctrine) — классы, сгенерированные в runtime, наследующие от сущности и переопределяющие геттеры связей. При первом обращении к связи происходит загрузка. Генерация прокси требует записи в файловую систему (директория proxies/), что может быть проблемой в read-only контейнерах.

Производительность

ORM вносит издержки на трёх уровнях:

  1. Метаданные и анализ
    При первом использовании класса сущности ORM парсит аннотации/атрибуты и строит метамодель. Это ресурсоёмко — поэтому в production обязательно используются кэши метаданных:

    • Doctrine: APCu, Redis, файловый кэш (PhpFileCache);
    • Eloquent: кэширование схемы через php artisan schema:cache.
  2. Гидрация
    Преобразование 10 000 строк в 10 000 объектов требует:

    • Выделения памяти под объекты (в PHP объект дороже массива);
    • Выполнения конструкторов, геттеров, обработчиков событий.
      Решение — partial hydration:
    // Doctrine: возвращает массивы, а не объекты
    $rows = $em->createQuery('SELECT u.id, u.email FROM User u')
    ->getResult(Query::HYDRATE_ARRAY);
    // Eloquent: возвращает коллекцию массивов
    $users = User::select('id', 'email')->get()->toArray();
  3. Генерация запросов
    Сложные условия, связанные через ORM, могут транслироваться в неоптимальный SQL (лишние JOIN’ы, подзапросы вместо оконных функций).
    Контрмеры:

    • Всегда проверять сгенерированный SQL через логгер ($em->getConfiguration()->setSQLLogger());
    • Использовать нативные запросы ($em->createNativeQuery()) для критичных участков;
    • Избегать чрезмерно сложных DQL/Eloquent-цепочек — иногда проще написать SQL вручную.

Безопасность и ORM

ORM не является гарантией безопасности. Уязвимости возникают при:

  • Динамическом построении условий на основе пользовательского ввода без валидации:

    // ОПАСНО: имя поля из URL
    $field = $_GET['sort'];
    User::orderBy($field, 'asc')->get(); // SQL-инъекция через имя колонки

    Решение: белый список допустимых полей.

  • Передаче нефильтрованных данных в where() без привязки параметров:

    // ОПАСНО — даже в Eloquent!
    User::whereRaw("email = '" . $_POST['email'] . "'")->first();

    Правильно:

    User::where('email', $_POST['email'])->first(); // использует prepared statement
  • Mass assignment («массовое присваивание»):

    $user = new User();
    $user->fill($_POST); // злоумышленник может установить is_admin = 1

    Защита:

    • В Doctrine: явное присваивание полей;
    • В Eloquent: указание $fillable или $guarded в модели.

Когда ORM оправдана, а когда — нет

ORM — инструмент, а не догма. Её применение целесообразно, если:

  • Приложение имеет сложную предметную область с насыщенными связями и бизнес-логикой, инкапсулированной в объектах;
  • Требуется переносимость между СУБД (Doctrine DBAL обеспечивает это на уровне запросов);
  • Команда владеет концепциями ORM и готова инвестировать в настройку (кэши, прокси, логирование).

ORM нецелесообразна, если:

  • Приложение — CRUD-интерфейс к простым таблицам (лучше использовать Query Builder);
  • Критична производительность на уровне тысяч запросов в секунду (нативные запросы + массивы эффективнее);
  • Разрабатывается микросервис с узким контрактом, где данные передаются в виде JSON, а не объектов (DTO + ручная маппинг-логика предпочтительнее);
  • Используется legacy-СУБД или нестандартный SQL-диалект, плохо поддерживаемый ORM.

Гибридные подходы

Профессиональные PHP-приложения редко используют только ORM. На практике применяется слоистая архитектура:

  • Слой домена: чистые объекты (POPO), без зависимости от ORM;
  • Слой персистентности: репозитории на основе Doctrine/Eloquent, но с интерфейсами, позволяющими подменить реализацию;
  • Критичные пути: вынесение в отдельные сервисы с использованием DBAL или PDO напрямую.

Пример (DDD-стиль с Doctrine):

interface UserRepositoryInterface
{
public function findById(UserId $id): ?User;
public function save(User $user): void;
}

class DoctrineUserRepository implements UserRepositoryInterface
{
public function findById(UserId $id): ?User
{
// Использует Doctrine EntityManager
return $this->em->find(User::class, $id->value());
}

public function save(User $user): void
{
$this->em->persist($user);
$this->em->flush();
}
}

// В CLI-задаче или миграции — можно подменить на SQLite-реализацию для тестов

Такой подход сохраняет преимущества ORM (типизация, события, Unit of Work) и избегает её ловушек (жёсткая привязка к фреймворку, неявные запросы).


Миграции и управление схемой: контроль над эволюцией данных

Миграция — это идемпотентная, упорядоченная операция изменения схемы базы данных. От простого SQL-скрипта миграция отличается тем, что:

  • Имеет уникальный идентификатор (обычно временная метка или версия);
  • Фиксирует своё состояние выполнения в специальной служебной таблице (например, phinxlog, doctrine_migration_versions);
  • Предоставляет механизм отката (down()), хотя на практике он редко используется в production.

Архитектура систем миграций

Все популярные инструменты (Phinx, Doctrine Migrations, Laravel Migrations) следуют одной и той же схеме:

  1. Генерация файла миграции
    Выполняется вручную через CLI или автоматически по diff’у:

    # Phinx
    vendor/bin/phinx create AddEmailToUsers

    # Doctrine
    bin/console doctrine:migrations:diff

    # Laravel
    php artisan make:migration add_email_to_users
  2. Содержимое миграции
    Содержит два метода: up() — применение изменений, down() — откат. Пример на Phinx (ближе к «чистому» SQL):

    public function up(): void
    {
    $this->table('users')
    ->addColumn('email', 'string', ['limit' => 255, 'null' => false])
    ->addIndex(['email'], ['unique' => true])
    ->update();
    }

    public function down(): void
    {
    $this->table('users')->removeColumn('email')->update();
    }
  3. Выполнение
    Инструмент:

    • Считывает список всех миграций;
    • Сравнивает с записями в служебной таблице;
    • Выполняет только ещё не применённые, в порядке возрастания идентификатора;
    • Фиксирует успешное выполнение в служебной таблице.

Идемпотентность и безопасность

Идемпотентность — свойство, при котором повторное выполнение операции не изменяет результат. Миграции должны быть идемпотентными, особенно в распределённых средах (CI/CD, blue-green deployment).

Как этого добиться:

  • Проверка существования объекта перед созданием:

    if (!$this->hasTable('logs')) {
    $this->table('logs')->create(); // Phinx
    }

    В PDO/mysqli — через запросы к information_schema:

    SELECT COUNT(*) FROM information_schema.tables
    WHERE table_schema = DATABASE() AND table_name = 'logs';
  • Использование IF NOT EXISTS (MySQL 8.0+, PostgreSQL 9.1+):

    CREATE TABLE IF NOT EXISTS logs (...);
    ALTER TABLE users ADD COLUMN IF NOT EXISTS email VARCHAR(255);
  • Транзакционность миграций
    В PostgreSQL все DDL-операции транзакционны — миграция либо полностью применяется, либо откатывается.
    В MySQL (InnoDB) — не все: CREATE TABLE, DROP TABLE, ALTER TABLE неявно коммитят текущую транзакцию. Поэтому:

    • Нельзя обернуть несколько DDL-команд в одну транзакцию;
    • Откат миграции down() должен быть продуман как самостоятельная операция, а не как ROLLBACK.

Автоматическая генерация diff’ов: плюсы и риски

Doctrine Migrations и Laravel Schema Builder позволяют генерировать миграции на основе сравнения текущей схемы БД и метаданных ORM.

Преимущества:

  • Экономия времени при разработке;
  • Снижение риска ошибок вручную написанного SQL.

Опасности:

  • Генератор может предложить DROP COLUMN, если поле убрано из сущности, но данные в нём критичны;
  • Не учитывает бизнес-ограничения (например, необходимость переноса данных перед удалением колонки);
  • Не различает логические и физические изменения (например, изменение VARCHAR(50)VARCHAR(100) в MySQL — это ALTER, но в некоторых СУБД требует полной перезаписи таблицы).

Рекомендация: автоматически сгенерированную миграцию всегда редактировать вручную, проверяя:

  • Порядок операций (сначала ADD COLUMN, потом UPDATE, потом DROP COLUMN);
  • Наличие WHERE в UPDATE, чтобы не затронуть лишние строки;
  • Обработку NULL-значений при добавлении NOT NULL колонки с дефолтом.

Пример безопасного расширения колонки:

-- Шаг 1: добавить колонку как NULL
ALTER TABLE users ADD COLUMN status TINYINT NULL;

-- Шаг 2: заполнить значения по умолчанию
UPDATE users SET status = 1 WHERE status IS NULL;

-- Шаг 3: сделать NOT NULL
ALTER TABLE users MODIFY COLUMN status TINYINT NOT NULL DEFAULT 1;

Такой подход избегает блокировки таблицы на длительное время (в отличие от единовременного ADD COLUMN ... NOT NULL DEFAULT 1, который в MySQL 5.6–5.7 требует перестроения всей таблицы).


Тестирование работы с БД: изоляция, воспроизводимость, скорость

Тестирование кода, взаимодействующего с БД, — одна из самых сложных задач в PHP из-за побочных эффектов, состояния и зависимости от внешней системы. Существует три уровня тестирования, каждый со своими компромиссами.

1. Моки и заглушки (Unit-тесты)

Цель — проверить логику вне работы с БД. Достигается через:

  • Интерфейсы репозиториев (как в примере выше с UserRepositoryInterface);
  • Mock-объекты (PHPUnit createMock(), Mockery);
  • Возвращение фиксированных данных.

Пример:

$mockRepo = $this->createMock(UserRepositoryInterface::class);
$mockRepo->method('findById')
->with(new UserId(42))
->willReturn(new User(/*...*/));

$service = new UserService($mockRepo);
$result = $service->activateUser(new UserId(42));
$this->assertTrue($result->isActive());

Преимущества:

  • Высокая скорость (мс);
  • Полная изоляция;
  • Покрытие edge-case’ов (например, null от findById).

Недостатки:

  • Не проверяется корректность SQL, маппинга, транзакций;
  • Риск рассогласования мока и реальной реализации.

2. In-memory БД (интеграционные тесты)

Используется SQLite в режиме :memory: — лёгкая, самодостаточная СУБД, полностью совместимая с PDO.

Пример настройки (PHPUnit + Doctrine):

protected function setUp(): void
{
$config = new Configuration();
$config->setMetadataDriverImpl(/* ... */);

$conn = DriverManager::getConnection([
'driver' => 'pdo_sqlite',
'memory' => true,
]);

$this->em = new EntityManager($conn, $config);
$this->em->getSchemaTool()->createSchema($this->em->getMetadataFactory()->getAllMetadata());
}

Преимущества:

  • Проверка DDL, DML, индексов (частично);
  • Поддержка транзакций;
  • Не требует внешней БД.

Ограничения:

  • Различия в диалектах SQL (например, отсутствие JSON-типов в старых SQLite, отличия в LIMIT, AUTO_INCREMENT);
  • Невозможность проверить производительность или блокировки.

3. Реальная БД в изолированной среде (e2e-тесты)

Запуск тестов против полноценной СУБД (MySQL, PostgreSQL), развёрнутой в Docker-контейнере или на CI.

Подходы:

  • Очистка БД перед каждым тестом через TRUNCATE (быстро, но сбрасывает автоинкремент);

  • Транзакционные тесты:
    Начать транзакцию в setUp(), откатить в tearDown():

    public function setUp(): void
    {
    $this->pdo->beginTransaction();
    }

    public function tearDown(): void
    {
    $this->pdo->rollBack();
    }

    Это гарантирует чистое состояние, но не работает для DDL (в MySQL) и фоновых задач.

  • Тестовые базы на лету:
    Создание уникальной БД для каждого запуска (test_db_12345), миграция схемы, запуск тестов, удаление. Используется в Laravel Dusk, Symfony Panther.

Выбор уровня зависит от критичности проверки:

  • Бизнес-логика → юнит-тесты с моками;
  • Корректность запросов → SQLite;
  • Интеграция с инфраструктурой → реальная БД.

Рефакторинг legacy-кода: пошаговая миграция на современные практики

Legacy-код в PHP часто содержит:

  • Прямые вызовы mysql_query() с подстановкой строк;
  • Глобальные переменные с подключениями;
  • SQL, размазанный по представлениям;
  • Отсутствие обработки ошибок.

Полная переписка — рискованна. Целесообразен пошаговый рефакторинг:

Этап 1. Внедрение централизованного подключения

Создать единый класс/сервис для получения соединения:

// legacy_db.php
$globalDb = new PDO(/*...*/);

// →
class DatabaseConnection
{
private static ?PDO $instance = null;
public static function getInstance(): PDO
{
return self::$instance ??= new PDO(/*...*/);
}
}

Заменить все mysql_connect() на DatabaseConnection::getInstance(). Это не улучшает безопасность, но даёт точку контроля.

Этап 2. Внедрение подготовленных выражений

Найти все места с конкатенацией строк в SQL:

// Было
$query = "SELECT * FROM users WHERE email = '" . $_GET['email'] . "'";
$result = mysql_query($query);

Заменить на:

// Стало
$stmt = $pdo->prepare("SELECT * FROM users WHERE email = ?");
$stmt->execute([$_GET['email']]);
$user = $stmt->fetch();

Автоматизировать через регулярные выражения (осторожно!) или статический анализ (PHPStan с кастомными правилами).

Этап 3. Выделение репозиториев

Группировать запросы по сущностям:

class UserRepository
{
public function findByEmail(string $email): ?array
{
$stmt = $this->pdo->prepare("SELECT * FROM users WHERE email = ?");
$stmt->execute([$email]);
return $stmt->fetch() ?: null;
}
}

Постепенно заменять прямые вызовы $db->query() на методы репозитория.

Этап 4. Внедрение ORM (опционально)

Только после того, как:

  • Все запросы локализованы в репозиториях;
  • Схема стабильна;
  • Команда готова к обучению.

Начать с одной сущности (например, User), оставить остальные на PDO. Использовать Doctrine DBAL как промежуточный слой:

// Вместо PDO::prepare()
$stmt = $connection->executeQuery(
'SELECT * FROM users WHERE email = ?',
[$email]
);

Затем перейти к полной ORM.

Ключевые метрики успеха

  • Снижение числа SQL-инъекций в SAST-отчётах;
  • Уменьшение количества SELECT *;
  • Рост покрытия тестами слоя доступа к данным;
  • Сокращение времени на внесение изменений в схему.