🔎 Buscar

🗄️ 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.

Wiki / Apuntes📖 Contenido

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. Activa ERRMODE_EXCEPTION SIEMPRE: 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 bindParam el valor se lee de la variable al ejecutar, por eso sirve para bucles: cambias la variable y re-ejecutas. Con bindValue el 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::class puedes 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 :o el PDO::PARAM_INT es 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=utf8mb4 en 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 el down()). 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/JOIN frecuentes y los UNIQUE. 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/deleted son 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_hash con PASSWORD_DEFAULT es 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 $fillable definido 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

Estudio · Recursos de todo el mundo (inglés, chino, japonés, español, francés, ruso…) curados y traducidos al español.