Работа с базами данных из PHP
Разработчику АрхитекторуКраткий вход по PDO: подключение и безопасные запросы. Сквозной пример "форма → БД": от HTML-формы до записи.
Работа с БД
Статья большая, поэтому удобный маршрут чтения такой:
- Быстрый старт и базовая безопасность: PDO.
- Сквозной пример "форма → валидация → INSERT": От HTML-формы до записи в базу данных на PHP.
- Загрузка файлов и хранение метаданных в БД: Загрузка файлов и валидация в PHP.
- Обработка ошибок SQL и инфраструктурных сбоев: Обработка исключений в прикладном коде PHP.
Если вы учите тему впервые, сначала освойте стабильный CRUD на PDO, потом переходите к MySQLi, миграциям, профилированию и ORM.
Основы работы с данными
PHP изначально создавался как язык сценариев для генерации динамических веб-страниц на основе данных, и с самого начала его развития поддержка работы с внешними источниками данных — в первую очередь, с реляционными базами данных — являлась одним из ключевых приоритетов. Сегодня, несмотря на эволюцию языка и появление альтернативных подходов (ORM, GraphQL-интерфейсы, NoSQL), работа с базами данных остаётся фундаментальной составляющей большинства PHP-приложений. Понимание того, как PHP взаимодействует с данными — от низкоуровневого чтения файлов до абстрактных драйверов подключения к СУБД — необходимо для грамотного проектирования, отладки и оптимизации.
ORM — это технология, которая связывает объекты программного кода с таблицами базы данных. Она позволяет работать с данными через объекты, а не через прямые SQL-запросы.
ORM строится на концепции маппинга объектов на реляционные таблицы. Каждый класс в коде соответствует таблице в базе данных. Каждое свойство класса отображается на колонку таблицы. Каждый экземпляр класса представляет строку в таблице.
Метаданные описывают соответствие между объектами и таблицами. Они задают имена таблиц, колонок, типы данных и связи между сущностями. Метаданные могут храниться в аннотациях, конфигурационных файлах или определяться через методы классов.
Система отслеживания изменений следит за состоянием объектов. Она фиксирует, какие поля были изменены, и формирует соответствующие запросы для сохранения данных. Отслеживание позволяет оптимизировать количество запросов к базе.
Менеджер сущностей управляет жизненным циклом объектов. Он отвечает за создание, загрузку, обновление и удаление сущностей. Менеджер координирует работу с базой данных и кэшированием.
Загрузчик связей выполняет выборку связанных данных. Он поддерживает различные стратегии загрузки: жадную, ленивую и выборочную. Загрузчик оптимизирует запросы для получения связанных сущностей.
Конструктор запросов предоставляет программный интерфейс для формирования запросов. Он позволяет строить сложные условия выборки, сортировки и соединения таблиц без написания сырого SQL.
Кэш запросов хранит результаты выполнения запросов. Он предотвращает повторное выполнение одинаковых запросов в рамках одного запроса к приложению. Кэш работает на уровне запросов и объектов.
Система миграций управляет изменениями структуры базы данных. Она отслеживает версии схемы данных и применяет изменения в порядке их создания. Миграции позволяют синхронизировать структуру базы между разными окружениями.
В прикладной разработке эти компоненты собираются в типичный контур:
- HTTP-слой получает и валидирует входные данные.
- Слой приложения вызывает репозиторий или сервис данных.
- Репозиторий выполняет подготовленные запросы или ORM-операции.
- Ошибки и конфликты ограничений (уникальность, внешние ключи) превращаются в понятный ответ API или UI.
Такой контур проще тестировать и масштабировать, чем прямые SQL-вызовы из шаблонов или контроллеров.
Как 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 к базе данных — это взаимодействие через многоуровневую стек-архитектуру, состоящую из трёх ключевых компонентов:
- Расширение уровня языка — код, написанный на C, скомпилированный в модуль PHP (
.soили.dll), предоставляющий функции и классы в пользовательском пространстве. - Драйвер клиентской библиотеки СУБД — бинарная библиотека (например,
libmysqlclient,libpq,oci8), реализующая протокол общения с конкретной СУБД. - Сервер СУБД — отдельный процесс (например,
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), но всё равно опираются на один из трёх указанных механизмов на нижнем уровне. Понимание базового уровня позволяет эффективно отлаживать проблемы, возникающие при их использовании.
MySQLi
Что такое MySQLi
MySQLi (MySQL Improved Extension) — это встроенное расширение PHP для работы с базой данных MySQL, улучшенная версия старого расширения mysql_*. "i" в названии означает Improved (улучшенный).
Представим, что:
- MySQL - библиотека с книгами (данные);
- PHP - читатель (программа);
- MySQLi - библиотекарь, который ищет книги и приносит их читателю.
Процесс работы таков:
- Вы пишете SQL-запрос на PHP;
- MySQLi отправляет его MySQL-серверу;
- MySQL выполняет запрос;
- MySQLi получает результат и передает его вам.
MySQLi встроен в PHP начиная с версии 5.0.0. По умолчанию он есть в XAMPP, WAMP, OpenServer, MAMP.
Проверить наличие можно так:
<?php
phpinfo(); // Найдите раздел "mysqli"
// Или:
if (extension_loaded('mysqli')) {
echo "MySQLi установлен!";
} else {
echo "MySQLi не найден";
}
Разбор:
phpinfo()показывает активные расширения и их параметры в текущем окружении PHP.extension_loaded('mysqli')даёт программный способ проверить, доступен ли драйвер для MySQL.- Такой тест полезно запускать до инициализации подключения, чтобы падать с понятной диагностикой.
Чтобы подключиться к БД, можно использовать один из трёх способов:
- объектно-ориентированный;
- процедурный;
- с отдельными параметрами.
- Объектно-ориентированный:
Код ITЗагрузка примера кода…
Разбор:
new mysqli(...)открывает соединение и связывает его с выбранной базой данных.- Проверка
$mysqli->connect_errorвыполняется сразу, чтобы не продолжать работу в неконсистентном состоянии. set_charset('utf8mb4')настраивает корректную кодировку соединения и предотвращает проблемы с многобайтными символами.
- Процедурный:
<?php
$mysqli = mysqli_connect("localhost", "username", "password", "database_name");
if (!$mysqli) {
die("Ошибка: " . mysqli_connect_error());
}
mysqli_set_charset($mysqli, "utf8mb4");
?>
Разбор:
- Процедурный API делает то же самое, что и ОО-стиль, но через функции
mysqli_*. if (!$mysqli)отлавливает неудачное подключение, когдаmysqli_connectвернулfalse.- Явная установка
utf8mb4обязательна для правильной обработки русского текста и emoji.
- С отдельными параметрами:
<?php
$host = 'localhost';
$user = 'root';
$password = '';
$database = 'myapp';
$port = 3306; // Стандартный порт MySQL
$mysqli = new mysqli($host, $user, $password, $database, $port);
if ($mysqli->connect_error) {
throw new Exception("Connection failed: " . $mysqli->connect_error);
}
?>
Разбор:
- Параметры подключения вынесены в переменные, поэтому конфигурацию проще менять между средами.
- Передача
$portполезна, когда сервер слушает нестандартный порт. - Исключение вместо
die()позволяет обрабатывать сбой подключения в общем обработчике ошибок приложения.
PHP автоматически закроет соединение при завершении скрипта, но явное закрытие — хорошая практика:
// Автоматическое закрытие при завершении скрипта
// Но лучше закрыть явно:
$mysqli->close();
// Или для процедурного стиля:
mysqli_close($mysqli);
Разбор:
- ОО- и процедурный варианты делают одно и то же — закрывают активное соединение с БД.
- Явное закрытие снижает потребление ресурсов в длинных сценариях и фоновых процессах.
- Такая привычка упрощает контроль жизненного цикла соединений в больших проектах.
stmt
$stmt — это объект подготовленного запроса (statement), возвращаемый методом prepare() объекта MySQLi. Это контейнер, который хранит ваш SQL-запрос после его подготовки, но до выполнения.
Создаётся он так:
$stmt = $this->db->prepare("INSERT INTO users ...");
Разбор:
prepare()создаёт объектmysqli_stmtи сохраняет SQL-шаблон на стороне драйвера.- На этом шаге запрос ещё не выполняется — подготавливается только структура с плейсхолдерами.
- Переменная
$stmtслужит контейнером для всех следующих шагов: привязки параметров, запуска и получения результата.
prepare() готовит SQL-запрос к выполнению, возвращает объект класса mysqli_stmt и присваивается переменной $stmt.
С $stmt можно привязывать параметры, выполнять запросы, получать результат и многое другое:
Код ITЗагрузка примера кода…
Разбор:
bind_param("sss", ...)указывает типы параметров и связывает значения с?в SQL-шаблоне.execute()выполняет подготовленный запрос с текущими значениями связанных переменных.get_result()применяют для выборок, аaffected_rowsпоказывает, сколько строк изменили операции записи.close()завершает работу statement и освобождает занятые ресурсы.
Наглядный пример разницы:
// Без подготовленных запросов (опасно!):
$sql = "SELECT * FROM users WHERE email = '$email'"; // SQL-инъекция!
// С подготовленным запросом (безопасно):
$stmt = $this->db->prepare("SELECT * FROM users WHERE email = ?");
$stmt->bind_param("s", $email); // ? заменяется на $email
$stmt->execute(); // Выполняем
$result = $stmt->get_result(); // Получаем результат
Разбор:
- Первый фрагмент показывает уязвимый путь — прямую подстановку
$emailв SQL-строку. - Второй фрагмент фиксирует SQL-структуру и передаёт значение отдельно через параметр.
- Это устраняет риск SQL-инъекции и делает код устойчивым к спецсимволам во входных данных.
- Такой паттерн стоит применять для всех входов из форм, API и query-параметров.
Жизненный цикл $stmt включает в себя:
- подготовку;
- привязку;
- выполнение;
- получение данных;
- закрытие.
Код ITЗагрузка примера кода…
Разбор:
- Здесь показан полный рабочий цикл prepared statement без пропуска обязательных этапов.
- Типовая строка
bind_param("si", $name, $age)связывает строку и целое число с параметрами запроса. - Для
SELECTдополнительно идёт чтение результата черезget_result()иfetch_assoc(). - Финальное закрытие statement завершает операцию и освобождает серверные/клиентские буферы.
CRUD-операции через MySQLi
Перед каждым CRUD-действием полезно пройти короткий чек:
- Проверена ли входная валидация на уровне PHP.
- Используется ли подготовленный запрос вместо конкатенации строки.
- Понятно ли, как обрабатывается ошибка (исключение, лог, HTTP-код).
- Нужна ли транзакция для согласованности нескольких запросов.
CREATE (Создание/Вставка данных)
Безопасный способ (подготовленные запросы):
Код ITЗагрузка примера кода…
Разбор:
- Подготовленный
INSERTс плейсхолдерами отделяет SQL от пользовательских данных. bind_param("ssi", ...)фиксирует ожидаемые типы и повышает предсказуемость выполнения.- После
execute()можно получить ID созданной записи через$stmt->insert_id. - При ошибке используйте
$stmt->errorдля диагностики и логирования.
Простой способ (но менее безопасный):
<?php
$query = "INSERT INTO users (name, email, age) VALUES ('$name', '$email', $age)";
if ($mysqli->query($query)) {
echo "Успешно! ID: " . $mysqli->insert_id;
}
?>
Разбор:
- Здесь SQL собирается строкой, поэтому спецсимволы во входе способны поменять смысл запроса.
- Такой подход пригоден только для учебного контраста и не должен использоваться в продакшене.
- Безопасная альтернатива в этой же главе —
prepare()+bind_param().
READ (Чтение данных)
Получение одной записи:
Код ITЗагрузка примера кода…
Разбор:
- Запрос получает одну запись по
idчерез параметризированное условиеWHERE id = ?. bind_param("i", $user_id)гарантирует целочисленный тип идентификатора.fetch_assoc()возвращает строку как массивколонка => значение, удобный для вывода или JSON.- Такой шаблон подходит для endpoint'ов вида "получить сущность по ID".
Получение всех записей:
Код ITЗагрузка примера кода…
Поиск с условиями:
<?php
$min_age = 18;
$search = "%ов%"; // Поиск по части имени
$stmt = $mysqli->prepare("SELECT * FROM users WHERE age > ? AND name LIKE ?");
$stmt->bind_param("is", $min_age, $search);
$stmt->execute();
$result = $stmt->get_result();
$users = $result->fetch_all(MYSQLI_ASSOC);
?>
UPDATE (Обновление данных)
Код ITЗагрузка примера кода…
DELETE (Удаление данных)
Код ITЗагрузка примера кода…
Методы и свойства MySQLi
Свойства объекта mysqli
$mysqli->affected_rows— количество строк, изменённых последним запросом$mysqli->connect_errno— код ошибки подключения (0 при успешном соединении)$mysqli->connect_error— текст ошибки подключения$mysqli->errno— код последней ошибки$mysqli->error— текст последней ошибки$mysqli->error_list— массив со всеми ошибками последнего запроса$mysqli->field_count— количество полей в последнем результате$mysqli->insert_id— ID последней вставленной записи (AUTO_INCREMENT)$mysqli->server_info— информация о версии MySQL-сервера$mysqli->client_info— информация о версии MySQL-клиента$mysqli->host_info— информация о хосте подключения$mysqli->protocol_version— версия протокола MySQL$mysqli->thread_id— ID текущего потока соединения$mysqli->warning_count— количество предупреждений от последнего запроса$mysqli->sqlstate— SQLSTATE-код последней ошибки
Методы объекта mysqli
Подключение и настройка
__construct()— создание объекта и подключение к базе данныхclose()— закрытие соединения с базой данныхset_charset()— установка кодировки соединенияselect_db()— выбор активной базы данныхoptions()— установка опций соединенияinit()— инициализация объектаmysqliбез немедленного подключенияreal_connect()— установка соединения после вызоваinit()kill()— завершение указанного потока сервера
Подключение к базе данных и проверка состояния:
Код ITЗагрузка примера кода…
Выполнение запросов
query()— выполнение SQL-запроса (буферизованный результат)prepare()— подготовка SQL-запроса с параметрами (возвращаетmysqli_stmt)multi_query()— выполнение нескольких SQL-запросов за один вызовreal_query()— выполнение SQL-запроса без автоматической буферизации результатаuse_result()— получение небуферизованного результата запросаstore_result()— получение буферизованного результата запроса
Выполнение простого запроса и получение результата:
Код ITЗагрузка примера кода…
Работа с подготовленными запросами:
Код ITЗагрузка примера кода…
Работа с результатами
get_result()— получение результата из подготовленного запроса какmysqli_resultfetch_all()— получение всех строк результата (поддерживает флаги —MYSQLI_ASSOC,MYSQLI_NUM,MYSQLI_BOTH)fetch_array()— получение одной строки в виде массиваfetch_assoc()— получение одной строки в виде ассоциативного массиваfetch_object()— получение одной строки в виде объектаfetch_row()— получение одной строки в виде индексированного массиваfield_seek()— перемещение указателя к заданному полюfree_result()— освобождение памяти, занятой результатом запросаdata_seek()— перемещение указателя к заданной строке результата
Примечание: свойство
affected_rowsчасто используется как методологический аналог для получения количества затронутых строк, хотя технически это свойство.
Транзакции
begin_transaction()— начало новой транзакции- подтверждение текущей транзакции
commit()
- откат текущей транзакции
rollback()
autocommit()— включение или отключение режима автокоммита- установка точки сохранения внутри транзакции
savepoint()
release_savepoint()— удаление ранее установленной точки сохранения
Транзакции и управление состоянием:
Код ITЗагрузка примера кода…
Безопасность и экранирование
real_escape_string()— экранирование специальных символов в строке для безопасного использования в SQLescape_string()— устаревший псевдоним дляreal_escape_string()
Экранирование строк и безопасность:
Код ITЗагрузка примера кода…
Информация о сервере и соединении
get_server_info()— получение информации о версии сервера MySQLget_client_info()— получение информации о версии клиентской библиотекиget_host_info()— получение информации о хосте подключенияget_proto_info()— получение версии используемого протоколаstat()— получение текущего статуса сервера MySQLping()— проверка активности соединения с серверомcharacter_set_name()— получение текущей кодировки соединения
Дополнительные методы
next_result()— переход к следующему результату при использованииmulti_query()more_results()— проверка наличия дополнительных результатов послеmulti_query()refresh()— обновление состояния соединенияssl_set()— настройка SSL-параметров для защищённого соединенияchange_user()— смена пользователя в текущем соединении без переподключенияdump_debug_info()— запись отладочной информации сервера в логdebug()— включение отладочного режима для соединения
Свойства объекта mysqli_stmt (подготовленный запрос)
$stmt->affected_rows— количество строк, изменённых выполненным запросом$stmt->errno— код последней ошибки, связанной с запросом$stmt->error— текст последней ошибки$stmt->error_list— массив всех ошибок, возникших при работе с запросом$stmt->insert_id— ID последней вставленной записи (AUTO_INCREMENT)$stmt->num_rows— количество строк в результате (доступно только послеstore_result())$stmt->param_count— количество параметров в подготовленном запросе$stmt->field_count— количество полей в результирующем наборе
Методы объекта mysqli_stmt (подготовленный запрос)
Подготовка и выполнение
prepare()— подготовка SQL-запроса с параметрами; обычно вызывается через$mysqli->prepare()bind_param()— привязка переменных к параметрам запроса по типу (i,d,s,b)bind_result()— привязка переменных к полям результирующего набора- выполнение подготовленного запроса
execute()
close()— закрытие подготовленного запроса и освобождение связанных ресурсовreset()— сброс состояния запроса для повторного использования без повторной подготовкиfree_result()— освобождение памяти, занятой результатом запроса
Получение результатов
get_result()— получение результата в виде объектаmysqli_result(требует расширения mysqlnd)store_result()— буферизация результата на стороне клиентаfetch()— извлечение следующей строки результата при использованииbind_result()fetch_all()— получение всех строк результата сразу (требует mysqlnd)data_seek()— перемещение внутреннего указателя к заданной строке (работает только послеstore_result())num_rows— свойство, содержащее количество строк в результате (доступно только послеstore_result())result_metadata()— получение метаданных результата в виде объектаmysqli_result, описывающего структуру полей
Работа с большими данными и метаданными:
Код ITЗагрузка примера кода…
Дополнительные методы
attr_set()— установка атрибутов подготовленного запросаattr_get()— получение текущего значения атрибута запросаsend_long_data()— отправка больших объёмов данных (например, BLOB) частямиmore_results()— проверка наличия дополнительных результирующих наборов послеmulti_query()next_result()— переход к следующему результирующему набору
Свойства объекта mysqli_result (результат запроса)
$result->num_rows— количество строк в результирующем наборе$result->field_count— количество полей (столбцов) в результирующем наборе$result->current_field— индекс текущего поля (используется при переборе метаданных)
Методы объекта mysqli_result
Извлечение данных
fetch_assoc()— получение одной строки в виде ассоциативного массиваfetch_array()— получение одной строки в виде массива с числовыми и/или ассоциативными ключамиfetch_object()— получение одной строки в виде объекта stdClass или указанного классаfetch_row()— получение одной строки в виде индексированного массиваfetch_all()— получение всех строк результата за один вызов
Навигация по результату
data_seek()— перемещение указателя к заданной строке по её номеруfield_seek()— перемещение указателя к заданному полю по его индексу
Получение метаданных
fetch_field()— получение информации о текущем поле в виде объектаfetch_fields()— получение массива объектов с информацией обо всех полях результатаfetch_field_direct()— получение информации о поле по его индексу
Освобождение ресурсов
free_result()— освобождение памяти, занятой результатом запросаclose()— псевдоним дляfree_result()free()— ещё один псевдоним дляfree_result()
Файлы и БД
Как PHP работает с файлами
Файловая система — один из самых ранних и надёжных способов хранения данных в веб-приложениях.
PHP предоставляет два уровня API для работы с файлами:
- низкоуровневый (на основе C-подобных функций
fopen,fread,fwrite,fclose); - высокоуровневый (например,
file_get_contents,file_put_contents).
Этот код демонстрирует самый простой способ работы с файлами, когда весь файл обрабатывается целиком:
Код ITЗагрузка примера кода…
А этот пример показывает работу с ресурсами, буферизацией и ручным управлением потоком данных:
Код ITЗагрузка примера кода…
Выбор уровня зависит от задачи — для простых операций чтения/записи целиком предпочтителен высокоуровневый API, для потоковой обработки, случайного доступа или работы с блокировками — низкоуровневый.
Важнейшее понятие — ресурс (resource). При открытии файла функцией fopen() возвращается дескриптор ресурса — ссылка на открытый файловый дескриптор в ядре ОС. Этот ресурс автоматически освобождается при завершении скрипта, но явное закрытие через fclose() рекомендуется: это освобождает системные ресурсы раньше, предотвращает превышение лимита открытых файлов и позволяет корректно сбросить буферы.
PHP поддерживает работу с блокировками файлов через flock(). Это механизм координации доступа нескольких процессов к одному файлу. Например, при записи в лог-файл из нескольких параллельных запросов без блокировки возможна перемешка строк. Рекомендуемый паттерн — открыть файл в режиме 'a' (добавление), выполнить flock($handle, LOCK_EX), записать данные, сбросить буфер fflush(), снять блокировку flock($handle, LOCK_UN), закрыть файл. Следует учитывать, что flock() реализует консультативную блокировку — она работает только если все процессы используют её явно; в некоторых файловых системах (например, NFS) поведение может отличаться.
Блокировка файлов:
Код ITЗагрузка примера кода…
Файлы также используются для реализации сессий (session.save_handler = files по умолчанию): данные сессии сериализуются и сохраняются в файл с именем вида sess_<session_id>. Это простое, но масштабируемое решение, однако при высокой нагрузке и распределённой архитектуре предпочтительны альтернативы — хранение в Redis или базе данных.
PHP корректно обрабатывает различия в именах файлов и путях между операционными системами. Константа DIRECTORY_SEPARATOR позволяет писать кроссплатформенный код. При загрузке через форму дополнительно нужны проверка MIME по содержимому (finfo), is_uploaded_file() и политика имён на диске — см. Загрузка файлов и валидация в PHP.
При работе с именами файлов, поступающими от пользователя, требуется строгая валидация — проверка на попытку выхода за пределы разрешённой директории (directory traversal), нормализация пути (realpath()), фильтрация недопустимых символов.
Безопасная работа с путями и защита от атак:
Код ITЗагрузка примера кода…
Временные файлы создаются автоматически при загрузке ($_FILES['file']['tmp_name']) или вручную через tmpfile() либо tempnam(). Они удаляются при завершении скрипта, но при длительных операциях (например, конвертация изображения) лучше явно удалять их после использования, чтобы избежать накопления мусора в /tmp.
Работа с временными файлами и загрузкой:
Код ITЗагрузка примера кода…
Безопасность взаимодействия с базой данных
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 ДАННЫЕ 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, ,pg_stat_statements(расширение для учёта статистики по запросам).
- В MySQL —
EXPLAIN (ANALYZE, BUFFERS)
- На уровне 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() быстро исчерпает лимит. Решение — итеративная, потоковая обработка.
Как драйверы получают данные от СУБД
Существует два режима получения результата:
- Буферизованный (buffered): драйвер получает все данные сразу и сохраняет их в памяти PHP. Это позволяет использовать
rowCount(), делать повторныеfetch()без повторного запроса. По умолчанию используется вPDO::query(),mysqli::query(). - Небуферизованный (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 ДАННЫЕ INFILE(MySQL) /COPY(PostgreSQL): Запись данных во временный файл (например, в/tmp/export.csv), затем выполнение:
LOAD ДАННЫЕ 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 в клиенте (пакет команд ради скорости сети, без атомарности). EXEC не делает ROLLBACK: при ошибке одной команды остальные в блоке всё равно выполнятся. Для условной логики и снятия блокировок по токену используют Lua; см. Redis в энциклопедии.
Общая рекомендация
NoSQL не заменяет SQL — он решает другие задачи. Документные БД эффективны при:
- Частом изменении структуры данных;
- Вложенных иерархиях;
- Горизонтальном масштабировании записи.
Но они не обеспечивают:
- JOIN’ы (приходится денормализовывать);
- Строгие ограничения целостности;
- Гарантированные отчёты в реальном времени.
Гибридные архитектуры (SQL для транзакций, NoSQL для аналитики/кэша) — распространённая и обоснованная практика.
ORM в PHP
ORM — это программная прослойка, задача которой — отобразить реляционные структуры (таблицы, строки, связи) в объектную модель (классы, экземпляры, ассоциации). В PHP ORM не является частью ядра — все решения реализованы на уровне пользовательского кода или через сторонние библиотеки. Это принципиально отличает PHP от языков вроде Java (JPA/Hibernate) или C# (.NET Entity Framework), где ORM глубоко интегрирована в платформу.
Основные реализации в экосистеме PHP
-
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.
-
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).
- Простота начала: минимальная конфигурация, соглашения по именованию (таблица
-
Propel
Более ранняя ORM (появилась до Doctrine), также использует Data Mapper. Отличается генерацией классов на основе XML-схемы. Сегодня используется редко, но остаётся в legacy-проектах. -
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.
- Поля, не указанные в SELECT, остаются неинициализированными (в Doctrine —
-
Unit of Work (паттерн) — центральный механизм Doctrine. Отслеживает изменения в отслеживаемых объектах (managed entities) и при вызове
flush()генерирует минимально необходимый набор SQL-запросов:- Новые объекты →
INSERT; - Изменённые →
UPDATEтолько по тем полям, которые реально изменились (через tracking изменений); - Удалённые →
DELETE.
Это позволяет избежать ручного управления транзакциями на уровне бизнес-логики, но требует понимания границ жизненного цикла Unit of Work (обычно — один HTTP-запрос).
- Новые объекты →
-
Lazy Loading — отложенная загрузка связанных сущностей. При обращении к свойству
$user->postsORM автоматически выполняет .
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 вносит издержки на трёх уровнях:
-
Метаданные и анализ
При первом использовании класса сущности ORM парсит аннотации/атрибуты и строит метамодель. Это ресурсоёмко — поэтому в production обязательно используются кэши метаданных:- Doctrine — APCu, Redis, файловый кэш (
PhpFileCache); - Eloquent: кэширование схемы через
php artisan schema:cache.
- Doctrine — APCu, Redis, файловый кэш (
-
Гидрация
Преобразование 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();
- Генерация запросов
Сложные условия, связанные через ORM, могут транслироваться в неоптимальный SQL (лишние JOIN’ы, подзапросы вместо оконных функций).
Контрмеры:- Всегда проверять сгенерированный SQL через логгер (
$em->getConfiguration()->setSQLLogger()); - Использовать нативные запросы (
$em->createNativeQuery()) для критичных участков; - Избегать чрезмерно сложных DQL/Eloquent-цепочек — иногда проще написать SQL вручную.
- Всегда проверять сгенерированный 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):
Код ITЗагрузка примера кода…
Такой подход сохраняет преимущества ORM (типизация, события, Unit of Work) и избегает её ловушек (жёсткая привязка к фреймворку, неявные запросы).
Миграции и управление схемой — контроль над эволюцией данных
Миграция — это идемпотентная, упорядоченная операция изменения схемы базы данных. От простого SQL-скрипта миграция отличается тем, что:
- Имеет уникальный идентификатор (обычно временная метка или версия);
- Фиксирует своё состояние выполнения в специальной служебной таблице (например,
phinxlog,doctrine_migration_versions); - Предоставляет механизм отката (
down()), хотя на практике он редко используется в production.
Архитектура систем миграций
Все популярные инструменты (Phinx, Doctrine Migrations, Laravel Migrations) следуют одной и той же схеме:
- Генерация файла миграции
Выполняется вручную через CLI или автоматически по diff’у:
# Phinx
vendor/bin/phinx create AddEmailToUsers
# Doctrine
bin/console doctrine:migrations:diff
# Laravel
php artisan make:migration add_email_to_users
- Содержимое миграции
Содержит два метода: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();
}
- Выполнение
Инструмент:- Считывает список всех миграций;
- Сравнивает с записями в служебной таблице;
- Выполняет только ещё не применённые, в порядке возрастания идентификатора;
- Фиксирует успешное выполнение в служебной таблице.
Идемпотентность и безопасность
Идемпотентность — свойство, при котором повторное выполнение операции не изменяет результат. Миграции должны быть идемпотентными, особенно в распределённых средах (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 *; - Рост покрытия тестами слоя доступа к данным;
- Сокращение времени на внесение изменений в схему.
Базовый разбор HTTP и HTTPS находится в отдельной статье — HTTP как основа веб-интеграций.