🗄️ Bases de datos en PHP
PDO a fondo con prepared statements y transacciones, conexión segura y pooling, migraciones con Doctrine y Phinx, Eloquent vs Doctrine, optimización con índices y eager loading, locks y Redis.
Bases de datos en PHP
Toda app PHP vive o muere por su capa de datos: conexiones, queries, transacciones y caché. Este artículo cubre PDO a fondo (la API nativa para cualquier base), el patrón de migraciones, los dos grandes ORMs (Eloquent y Doctrine), optimización con índices y eager loading, y Redis como capa de caché y colas.
PDO a fondo
PDO (PHP Data Objects) es la extensión nativa de PHP para bases de datos: funciona igual con MySQL, PostgreSQL, SQLite y más. Cada driver (pdo_mysql, pdo_pgsql, pdo_sqlite) aporta el dialecto; la API es común.
// Conexión con DSN (Data Source Name)
$dsn = 'mysql:host=127.0.0.1;port=3306;dbname=mi_app;charset=utf8mb4';
$pdo = new PDO($dsn, 'usuario', 'secreto');
// Exigir excepciones en todos los errores (lo NO opcional)
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// Prevenir inyección en emulated prepares
$pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
⚠️
PDO::ERRMODE_SILENT(el default) hace que los errores se ignoren. ActivaERRMODE_EXCEPTIONSIEMPRE: los errores de BD deben ser excepciones, no silencios.
Prepared statements: la defensa contra SQL injection
Un prepared statement separa plan (la query con marcadores) de datos (los valores). El servidor compila la query y los valores viajan por un canal aparte: no se concatenan, no hay inyección posible.
// ✅ Correcto: marcadores posicionales
$sql = 'SELECT id, email FROM usuarios WHERE email = ? AND activo = ?';
$stmt = $pdo->prepare($sql);
$stmt->execute([$_POST['email'], 1]);
// ✅ Correcto: marcadores con nombre
$sql = 'INSERT INTO usuarios (nombre, email) VALUES (:nombre, :email)';
$stmt = $pdo->prepare($sql);
$stmt->execute([':nombre' => 'Ana', ':email' => 'ana@example.com']);
// ❌ NUNCA: concatenación de datos en la query
$sql = "SELECT * FROM usuarios WHERE email = '" . $_POST['email'] . "'"; // ¡inyección!
💡 Con
bindParamel valor se lee de la variable al ejecutar, por eso sirve para bucles: cambias la variable y re-ejecutas. ConbindValueel valor se congela al bindear.
Fetch modes
// Array asociativo (por defecto, FETCH_ASSOC)
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Una sola columna
$emails = $pdo->query('SELECT email FROM usuarios')->fetchAll(PDO::FETCH_COLUMN);
// Filas afectadas por INSERT/UPDATE/DELETE
$afectadas = $stmt->rowCount();
💡 Con
PDO::FETCH_CLASS, Usuario::classpuedes hidratar cada fila en un objeto.
// Paginación segura: en LIMIT/OFFSET hay que tipar el bindValue como entero
$page = max(1, (int)($_GET['page'] ?? 1));
$stmt = $pdo->prepare('SELECT * FROM posts ORDER BY created_at DESC LIMIT :l OFFSET :o');
$stmt->bindValue(':l', 20, PDO::PARAM_INT);
$stmt->bindValue(':o', ($page - 1) * 20, PDO::PARAM_INT);
$stmt->execute();
$posts = $stmt->fetchAll();
⚠️ En
LIMIT :l OFFSET :oelPDO::PARAM_INTes obligatorio: sin él MySQL recibe un string y algunos drivers dan error de sintaxis.
Transacciones
Agrupa operaciones en una unidad atómica: o se aplican todas, o ninguna.
$pdo->beginTransaction();
try {
$pdo->exec('UPDATE cuentas SET saldo = saldo - 100 WHERE id = 1');
$pdo->exec('UPDATE cuentas SET saldo = saldo + 100 WHERE id = 2');
$pdo->commit();
} catch (Throwable $e) {
$pdo->rollBack();
throw $e; // la BD queda consistente
}
💡 Todo dentro de una transacción va en el mismo
try. Con pooling, la transacción debe abrirse y cerrarse dentro de la misma conexión del pool.
Conexión segura y pooling
Seguridad al conectar
- Usa usuarios de BD con menos privilegios que el app (jamás
root). charset=utf8mb4en el DSN evita problemas con emojis y, con prepared statements, es la defensa estándar contra SQL injection.- Credenciales en variables de entorno, nunca en el código.
// Cliente MySQL seguro: TLS obligatorio
$dsn = 'mysql:host=db.internal;dbname=mi_app;charset=utf8mb4';
$pdo = new PDO($dsn, getenv('DB_USER'), getenv('DB_PASS'), [
PDO::MYSQL_ATTR_SSL_CA => '/etc/ssl/certs/ca-cert.pem',
PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT => true,
]);
Pooling
Abrir una conexión por request es caro (handshake + TLS + auth). El pooling reutiliza conexiones:
| Estrategia | Cómo | Cuándo |
|---|---|---|
| Persistent connections | PDO::ATTR_PERSISTENT => true |
app simples con PHP-FPM |
| PHP-FPM + keepalive | conexión por worker de FPM, reutilizada | el estándar de facto |
| Pool de procesos (Swoole/Octane) | la app mantiene conexiones en memoria | apps long-running |
⚠️ Las conexiones persistentes sobreviven al script: si muere a mitad de una transacción, la siguiente request hereda una transacción abierta. Con pooling, verifica siempre el estado o usa un pool real (Swoole, RoadRunner) que gestione la limpieza.
// Conexión persistente: el driver reutiliza la conexión del worker
$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_PERSISTENT => true,
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
Migraciones
Las migraciones versionan el esquema en código: cada cambio es un archivo con up() (aplicar) y down() (deshacer).
Doctrine Migrations
composer require doctrine/migrations doctrine/dbal
bin/doctrine-migrations generate
bin/doctrine-migrations migrate
// Migraciones/Migration0001.php
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Migration0001 extends AbstractMigration
{
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE usuarios (
id INT AUTO_INCREMENT PRIMARY KEY,
nombre VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL UNIQUE
) ENGINE=InnoDB');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE usuarios');
}
}
Phinx
Phinx (de CakePHP) es independiente de framework y muy ligero:
composer require robmorgan/phinx
vendor/bin/phinx init
vendor/bin/phinx create CreateUsuarios
vendor/bin/phinx migrate
// db/migrations/20260101000001_create_usuarios.php
use Phinx\Migration\AbstractMigration;
final class CreateUsuarios extends AbstractMigration
{
public function change(): void
{
$table = $this->table('usuarios');
$table->addColumn('nombre', 'string', ['limit' => 255])
->addColumn('email', 'string', ['limit' => 255])
->addColumn('creado_en', 'datetime', ['default' => 'CURRENT_TIMESTAMP'])
->addIndex(['email'], ['unique' => true])
->create();
}
}
💡
change()es reversible por sí solo (Phinx infiere eldown()). Regla de oro: nunca edites una migración ya aplicada; añade una nueva.
Eloquent vs Doctrine: Active Record vs Data Mapper
| Eloquent (Laravel) | Doctrine (Symfony) | |
|---|---|---|
| Patrón | Active Record | Data Mapper |
| Entidad conoce la BD | sí (save, delete en el modelo) |
no (es un POJO) |
| Persistencia | el modelo se guarda solo | EntityManager->flush() |
| Sintaxis | User::where(...) |
$repo->findBy(...) / QueryBuilder |
| Curva de aprendizaje | corta | mayor, más control |
| Ideal para | MVP, apps pequeñas/medias | dominios complejos, empresariales |
| Migraciones | integradas en el framework | bundle de Doctrine |
// Eloquent (Active Record): la entidad se persiste a sí misma
$user = new User(['name' => 'Ana', 'email' => 'ana@x.com']);
$user->save(); // INSERT
$user->name = 'Ana M.';
$user->save(); // UPDATE
$user->delete(); // DELETE
// Doctrine (Data Mapper): el EntityManager es el que persiste
$user = new User();
$user->setName('Ana');
$user->setEmail('ana@x.com');
$em->persist($user); // registrar en la unidad de trabajo
$em->flush(); // ejecutar los cambios en lote
💡 ¿Cuál elegir? Eloquent brilla en velocidad de desarrollo y en Laravel es lo natural. Doctrine brilla en dominios complejos que necesitan control fino, o cuando quieres entidades limpias sin dependencias del framework. Para un CRUD simple en PHP plano, PDO o Phinx bastan.
Optimización: índices y el problema N+1
Índices
Un índice acelera WHERE, JOIN, ORDER BY y UNIQUE:
// Con Phinx
$table->addIndex(['email'], ['unique' => true]);
$table->addIndex(['user_id', 'status']); // índice compuesto
// Con PDO/MySQL directo
$pdo->exec('CREATE INDEX idx_posts_user ON posts (user_id)');
💡 Indexa las foreign keys, las columnas de
WHERE/JOINfrecuentes y losUNIQUE. No indexes columnas de baja cardinalidad (un booleano) ni crees índices que no usas: cada uno ralentiza los INSERT/UPDATE.
N+1 y eager loading
N+1: consultar N entidades y, por cada una, otra query para su relación = N+1 queries totales. La solución es cargar las relaciones con un JOIN o eager loading.
// PDO — el mismo problema resuelto con JOIN: 1 sola query
$sql = 'SELECT p.*, u.email AS autor_email
FROM posts p
JOIN users u ON u.id = p.user_id';
$posts = $pdo->query($sql)->fetchAll();
// En Eloquent con with() y en Doctrine con joins del QueryBuilder
$posts = Post::with('author')->get(); // 1 + 1 queries, no 1 + N
$posts = $em->createQueryBuilder()
->select('p', 'a')->from(Post::class, 'p')->leftJoin('p.author', 'a')
->getQuery()->getResult();
💡 Para detectarlo: en Laravel,
barryvdh/laravel-debugbar; en Symfony, el profiler. En Doctrine,fetch: 'EAGER'cuando la relación siempre se necesita.
Transacciones y locks en la práctica
Las transacciones evitan estados parciales; los locks evitan carreras entre requests concurrentes.
// LOCK FOR UPDATE: bloquea la fila hasta el commit
$pdo->beginTransaction();
$stmt = $pdo->prepare('SELECT saldo FROM cuentas WHERE id = ? FOR UPDATE');
$stmt->execute([$id]);
$saldo = (int)$stmt->fetchColumn();
if ($saldo >= 100) {
$pdo->prepare('UPDATE cuentas SET saldo = saldo - 100 WHERE id = ?')->execute([$id]);
$pdo->commit();
} else {
$pdo->rollBack();
throw new RuntimeException('Saldo insuficiente');
}
⚠️ Sin
FOR UPDATE, dos requests leen el mismo saldo y ambas deducen 100. El lock solo se libera al commit/rollback: la operación crítica va dentro de la misma transacción, y manténla corta (una transacción larga bloquea filas y degrada la concurrencia).
Redis en PHP
Redis es una base en memoria: caché, colas, sesiones, rankings y rate limiting. En PHP se usa predis (composer require predis/predis) o la extensión phpredis.
use Predis\Client;
$redis = new Client(['scheme' => 'tcp', 'host' => '127.0.0.1', 'port' => 6379]);
// Caché con expiración (TTL)
$redis->setex('cache:post:1', 3600, json_encode($post));
$json = $redis->get('cache:post:1'); // cache hit
$data = $json ? json_decode($json, true) : cargarDeBase(1); // cache miss
// Contadores, rankings y colas FIFO
$redis->incr('visitas');
$redis->zadd('ranking', 100, 'post:1');
$top = $redis->zrevrange('ranking', 0, 9);
$redis->lpush('cola:emails', json_encode(['to' => 'ana@x.com']));
$job = $redis->rpop('cola:emails');
💡 La invalidación de caché es parte del patrón: al actualizar un post, borra su clave. En Eloquent, los eventos
saved/deletedson el lugar natural.
CRUD completo y seguro con PDO
final class UsuarioRepository
{
public function __construct(private PDO $pdo) {}
public function crear(string $nombre, string $email, string $hash): int
{
$stmt = $this->pdo->prepare(
'INSERT INTO usuarios (nombre, email, password) VALUES (:n, :e, :p)'
);
$stmt->execute([':n' => $nombre, ':e' => $email, ':p' => $hash]);
return (int) $this->pdo->lastInsertId();
}
public function buscarPorEmail(string $email): ?array
{
$stmt = $this->pdo->prepare('SELECT * FROM usuarios WHERE email = :e');
$stmt->execute([':e' => $email]);
$row = $stmt->fetch();
return $row ?: null;
}
public function actualizarNombre(int $id, string $nombre): void
{
$stmt = $this->pdo->prepare('UPDATE usuarios SET nombre = :n WHERE id = :id');
$stmt->execute([':n' => $nombre, ':id' => $id]);
}
public function eliminar(int $id): void
{
$stmt = $this->pdo->prepare('DELETE FROM usuarios WHERE id = :id');
$stmt->execute([':id' => $id]);
}
}
// Uso con transacción en el flujo de registro
$pdo->beginTransaction();
try {
$hash = password_hash($clavePlana, PASSWORD_DEFAULT);
$id = $repo->crear($_POST['nombre'], $_POST['email'], $hash);
// ... insertar perfil, roles, etc.
$pdo->commit();
} catch (Throwable $e) {
$pdo->rollBack();
error_log($e->getMessage());
}
💡
password_hashconPASSWORD_DEFAULTes la única forma correcta de guardar contraseñas. Nunca MD5/SHA1.
CRUD completo con Eloquent
use App\Models\User;
// Create
$user = User::create([
'name' => $request->input('name'),
'email' => $request->input('email'),
'password' => bcrypt($request->input('password')),
]);
// Read (con paginación y eager loading)
$users = User::with('posts')->paginate(15);
// Update y Delete
$user->update(['name' => $request->input('name')]);
$user->delete();
// Transacción
DB::transaction(fn () => $user->posts()->create(['title' => 'Nuevo post']));
💡
User::create()solo funciona con$fillabledefinido en el modelo: sin mass assignment protection, un campo extra en la request podría asignarse a columnas no deseadas.
Cheatsheet
| Tarea | PDO | Eloquent |
|---|---|---|
| Query parametrizada | prepare() + execute() |
User::where('email', $e)->first() |
| Transacción | beginTransaction/commit/rollBack |
DB::transaction(...) |
| Eager loading | JOIN manual | with('posts') |
| Lock de fila | SELECT ... FOR UPDATE |
->lockForUpdate() |
| Migraciones | Phinx / Doctrine | php artisan migrate |
| Cache | predis / phpredis | Cache::remember(...) |
Para profundizar
- PHP Manual — PDO: la referencia oficial de conexiones, prepared statements y fetch modes.
- MySQL — SQL Injection Prevention: cómo MySQL ve la inyección y por qué los prepared statements la neutralizan.
- Doctrine Migrations Docs: versionado de esquema en profundidad.
- Phinx Docs: migraciones ligeras independientes de framework.
- Redis Commands: referencia de comandos para caché, colas y estructuras.
- Ruta completa: Backend con PHP.