Skip to content

Conexión desde PHP y Node ​

La base de datos cobra vida cuando tu aplicación la usa. En esta lección conectarás MySQL desde PHP con PDO y desde Node.js con mysql2, usando consultas preparadas (la defensa contra la inyección SQL), transacciones y variables de entorno. Terminarás con un mini CRUD de productos en ambos lenguajes.

Concepto ​

Tu aplicación necesita un driver (controlador): una librería que sabe hablar el protocolo de MySQL.

LenguajeDriver recomendadoAlternativas
PHPPDO (pdo_mysql)mysqli
Node.jsmysql2 (con promesas)mysql (antiguo), ORMs: Prisma, Sequelize, Drizzle, Knex

El flujo siempre es el mismo:

txt
1. Conectar (host, puerto, usuario, contraseña, base, charset)
2. Preparar la consulta con marcadores (?)
3. Ejecutar enviando los valores por separado
4. Leer resultados
5. Manejar errores (y transacciones)

La regla número uno: consultas preparadas ​

El problema: inyección SQL ​

php
<?php
// ¡NUNCA HAGAS ESTO!
$email = $_POST['email'];
$sql = "SELECT * FROM clientes WHERE email = '$email'";

Si un atacante escribe en el formulario ' OR '1'='1, la consulta queda:

sql
SELECT * FROM clientes WHERE email = '' OR '1'='1'
-- devuelve TODOS los clientes

Con '; DROP TABLE clientes; -- podría hacer cosas peores. La inyección SQL lleva décadas en el top de vulnerabilidades web.

La solución ​

Con una consulta preparada, la estructura del SQL y los datos viajan por separado. El servidor nunca interpreta los datos como código:

txt
Plantilla:  SELECT * FROM clientes WHERE email = ?
Valor:      "' OR '1'='1"        ← se trata como un texto literal, no como SQL

Regla de oro

Todo valor que venga del exterior (formularios, URL, cookies, APIs, archivos) se pasa con marcadores ? o :nombre. Nunca concatenes variables en el SQL. Si necesitas un nombre de columna dinámico (por ejemplo, para ordenar), valídalo contra una lista blanca.

PHP con PDO ​

Conexión ​

php
<?php
// db.php — conexión reutilizable
declare(strict_types=1);

function conectar(): PDO
{
    static $pdo = null;              // una sola conexión por petición
    if ($pdo !== null) {
        return $pdo;
    }

    $host = getenv('DB_HOST') ?: '127.0.0.1';
    $port = getenv('DB_PORT') ?: '3306';
    $name = getenv('DB_DATABASE') ?: 'tienda';
    $user = getenv('DB_USERNAME') ?: 'tienda_app';
    $pass = getenv('DB_PASSWORD') ?: '';

    $dsn = "mysql:host=$host;port=$port;dbname=$name;charset=utf8mb4";

    $pdo = new PDO($dsn, $user, $pass, [
        PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION, // errores como excepciones
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,       // filas como arreglos asociativos
        PDO::ATTR_EMULATE_PREPARES   => false,                  // preparadas reales en el servidor
    ]);

    return $pdo;
}
OpciónPor qué
charset=utf8mb4 en el DSNAcentos y emojis correctos (utf8mb4)
ERRMODE_EXCEPTIONLos errores lanzan excepciones en lugar de fallar en silencio
FETCH_ASSOC$fila['nombre'] en lugar de índices numéricos duplicados
EMULATE_PREPARES => falseTipos correctos (enteros como int) y preparadas nativas

Consultar ​

php
<?php
require __DIR__ . '/db.php';
$pdo = conectar();

// Varias filas con marcador posicional (?)
$stmt = $pdo->prepare('SELECT id, nombre, precio FROM productos WHERE precio < ? ORDER BY precio');
$stmt->execute([500]);
$productos = $stmt->fetchAll();

foreach ($productos as $p) {
    echo "{$p['nombre']}: \${$p['precio']}\n";
}

// Una fila con marcador con nombre (:email)
$stmt = $pdo->prepare('SELECT id, nombre FROM clientes WHERE email = :email');
$stmt->execute(['email' => 'ana@correo.com']);
$cliente = $stmt->fetch();          // false si no existe

if ($cliente === false) {
    echo "No encontrado\n";
}

// Un solo valor
$total = $pdo->query('SELECT COUNT(*) FROM productos')->fetchColumn();

Insertar, actualizar y borrar ​

php
<?php
// INSERT y obtener el id generado
$stmt = $pdo->prepare(
    'INSERT INTO productos (nombre, categoria_id, precio, stock) VALUES (?, ?, ?, ?)'
);
$stmt->execute(['Bocina portátil', 1, 749.00, 12]);
$nuevoId = (int) $pdo->lastInsertId();

// UPDATE y saber cuántas filas cambiaron
$stmt = $pdo->prepare('UPDATE productos SET precio = ? WHERE id = ?');
$stmt->execute([699.00, $nuevoId]);
echo $stmt->rowCount() . " fila(s) actualizada(s)\n";

// DELETE
$pdo->prepare('DELETE FROM productos WHERE id = ?')->execute([$nuevoId]);

LIKE e IN con marcadores ​

php
<?php
// LIKE: el comodín va en el VALOR, no en el SQL
$buscar = $_GET['q'] ?? '';
$stmt = $pdo->prepare('SELECT id, nombre FROM productos WHERE nombre LIKE ?');
$stmt->execute(['%' . $buscar . '%']);

// IN con una lista de tamaño variable: un ? por elemento
$ids = [1, 4, 7];
$marcadores = implode(',', array_fill(0, count($ids), '?'));   // "?,?,?"
$stmt = $pdo->prepare("SELECT id, nombre FROM productos WHERE id IN ($marcadores)");
$stmt->execute($ids);

// ORDER BY dinámico: lista blanca
$permitidas = ['nombre', 'precio', 'stock'];
$orden = in_array($_GET['orden'] ?? '', $permitidas, true) ? $_GET['orden'] : 'nombre';
$stmt = $pdo->query("SELECT id, nombre, precio FROM productos ORDER BY $orden");

Transacciones ​

php
<?php
function crearPedido(PDO $pdo, int $clienteId, array $items): int
{
    $pdo->beginTransaction();
    try {
        $pdo->prepare('INSERT INTO pedidos (cliente_id, fecha, estado) VALUES (?, CURDATE(), ?)')
            ->execute([$clienteId, 'pendiente']);
        $pedidoId = (int) $pdo->lastInsertId();

        $descontar = $pdo->prepare('UPDATE productos SET stock = stock - ? WHERE id = ? AND stock >= ?');
        $precio    = $pdo->prepare('SELECT precio FROM productos WHERE id = ?');
        $detalle   = $pdo->prepare(
            'INSERT INTO detalle_pedido (pedido_id, producto_id, cantidad, precio_unitario) VALUES (?, ?, ?, ?)'
        );

        foreach ($items as $productoId => $cantidad) {
            $descontar->execute([$cantidad, $productoId, $cantidad]);
            if ($descontar->rowCount() === 0) {
                throw new RuntimeException("Stock insuficiente del producto $productoId");
            }
            $precio->execute([$productoId]);
            $detalle->execute([$pedidoId, $productoId, $cantidad, $precio->fetchColumn()]);
        }

        $pdo->commit();
        return $pedidoId;
    } catch (Throwable $e) {
        $pdo->rollBack();
        throw $e;            // la capa superior decide qué mostrar al usuario
    }
}

// Uso: producto 2 x1, producto 7 x2
$id = crearPedido(conectar(), 5, [2 => 1, 7 => 2]);

Manejo de errores ​

php
<?php
try {
    $pdo->prepare('INSERT INTO clientes (nombre, email, fecha_registro) VALUES (?, ?, CURDATE())')
        ->execute(['Ana', 'ana@correo.com']);
} catch (PDOException $e) {
    if ($e->errorInfo[1] === 1062) {          // código MySQL de clave duplicada
        echo 'Ese correo ya está registrado.';
    } else {
        error_log($e->getMessage());          // registra el detalle técnico
        echo 'Ocurrió un error, intenta más tarde.';
    }
}

No muestres errores de BD al usuario

Un mensaje como SQLSTATE[42S02]: Base table or view not found: 1146 Table 'tienda.usuarios'... le revela a un atacante nombres de tablas y estructura. En producción, regístralo en un log y muestra un mensaje genérico.

mysqli (alternativa) ​

php
<?php
mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);   // excepciones
$db = new mysqli('127.0.0.1', 'tienda_app', 'secreto', 'tienda');
$db->set_charset('utf8mb4');

$stmt = $db->prepare('SELECT id, nombre FROM productos WHERE precio < ?');
$max = 500.0;
$stmt->bind_param('d', $max);        // d = double, i = int, s = string
$stmt->execute();
$resultado = $stmt->get_result();
while ($fila = $resultado->fetch_assoc()) {
    echo $fila['nombre'], "\n";
}

PDO es preferible: funciona con varios motores (MySQL, PostgreSQL, SQLite) y su API es más cómoda. Laravel usa PDO por debajo.

Node.js con mysql2 ​

Instalación y conexión con pool ​

bash
npm init -y
npm install mysql2 dotenv
env
# .env (agrégalo a .gitignore)
DB_HOST=127.0.0.1
DB_PORT=3306
DB_USER=tienda_app
DB_PASSWORD=secreto
DB_NAME=tienda
js
// db.js
import 'dotenv/config';
import mysql from 'mysql2/promise';

// Un pool reutiliza conexiones: crearlas es costoso
export const pool = mysql.createPool({
  host: process.env.DB_HOST,
  port: Number(process.env.DB_PORT ?? 3306),
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
  charset: 'utf8mb4',
  waitForConnections: true,
  connectionLimit: 10,        // máximo de conexiones simultáneas
  decimalNumbers: true        // DECIMAL como number (por defecto llegan como string)
});

(Usa "type": "module" en tu package.json para la sintaxis import.)

¿Por qué un pool?

Abrir una conexión por cada petición HTTP es lento y en hosting puede agotar max_user_connections. El pool mantiene unas cuantas conexiones abiertas y las presta a cada consulta.

decimalNumbers y el dinero

Por defecto mysql2 devuelve DECIMAL como texto ("899.00") para no perder precisión. Con decimalNumbers: true llegan como number, cómodo para precios normales. Para cálculos contables delicados, mantenlos como texto y usa una librería decimal.

Consultas ​

js
// consultas.js
import { pool } from './db.js';

// execute() = consulta preparada con marcadores ?
const [productos] = await pool.execute(
  'SELECT id, nombre, precio FROM productos WHERE precio < ? ORDER BY precio',
  [500]
);
console.table(productos);

// Una fila
const [filas] = await pool.execute('SELECT id, nombre FROM clientes WHERE email = ?', [
  'ana@correo.com'
]);
const cliente = filas[0] ?? null;

// INSERT
const [resultado] = await pool.execute(
  'INSERT INTO productos (nombre, categoria_id, precio, stock) VALUES (?, ?, ?, ?)',
  ['Bocina portátil', 1, 749.0, 12]
);
console.log('Nuevo id:', resultado.insertId);

// UPDATE / DELETE
const [upd] = await pool.execute('UPDATE productos SET precio = ? WHERE id = ?', [
  699.0,
  resultado.insertId
]);
console.log('Filas afectadas:', upd.affectedRows);

await pool.end(); // al terminar un script (en un servidor web no se cierra)

execute() usa preparadas reales en el servidor. query() también acepta ? (los escapa en el cliente) y además permite listas en IN (?):

js
const ids = [1, 4, 7];
const [rows] = await pool.query('SELECT id, nombre FROM productos WHERE id IN (?)', [ids]);

Transacciones ​

js
// pedidos.js
import { pool } from './db.js';

export async function crearPedido(clienteId, items) {
  const conn = await pool.getConnection();   // la transacción necesita UNA conexión fija
  try {
    await conn.beginTransaction();

    const [pedido] = await conn.execute(
      "INSERT INTO pedidos (cliente_id, fecha, estado) VALUES (?, CURDATE(), 'pendiente')",
      [clienteId]
    );

    for (const { productoId, cantidad } of items) {
      const [upd] = await conn.execute(
        'UPDATE productos SET stock = stock - ? WHERE id = ? AND stock >= ?',
        [cantidad, productoId, cantidad]
      );
      if (upd.affectedRows === 0) {
        throw new Error(`Stock insuficiente del producto ${productoId}`);
      }
      await conn.execute(
        `INSERT INTO detalle_pedido (pedido_id, producto_id, cantidad, precio_unitario)
         SELECT ?, id, ?, precio FROM productos WHERE id = ?`,
        [pedido.insertId, cantidad, productoId]
      );
    }

    await conn.commit();
    return pedido.insertId;
  } catch (err) {
    await conn.rollback();
    throw err;
  } finally {
    conn.release();                          // ¡devuelve la conexión al pool!
  }
}

Olvidar release()

Si no llamas conn.release() (por eso va en finally), la conexión nunca vuelve al pool. Tras 10 peticiones el pool se agota y tu API se queda "colgada" esperando.

Mini proyecto: CRUD de productos ​

Versión PHP (API JSON) ​

php
<?php
// api/productos.php — GET (listar/ver), POST (crear), PUT (editar), DELETE (borrar)
declare(strict_types=1);
require __DIR__ . '/../db.php';

header('Content-Type: application/json; charset=utf-8');
$pdo    = conectar();
$metodo = $_SERVER['REQUEST_METHOD'];
$id     = isset($_GET['id']) ? (int) $_GET['id'] : null;
$datos  = json_decode(file_get_contents('php://input'), true) ?? [];

function responder(int $codigo, $cuerpo): never
{
    http_response_code($codigo);
    echo json_encode($cuerpo, JSON_UNESCAPED_UNICODE);
    exit;
}

function validar(array $d): array
{
    $errores = [];
    if (trim($d['nombre'] ?? '') === '') $errores[] = 'El nombre es obligatorio';
    if (!is_numeric($d['precio'] ?? null) || $d['precio'] < 0) $errores[] = 'Precio inválido';
    if (!ctype_digit((string) ($d['stock'] ?? '0'))) $errores[] = 'Stock inválido';
    return $errores;
}

try {
    switch ($metodo) {
        case 'GET':
            if ($id) {
                $stmt = $pdo->prepare('SELECT * FROM productos WHERE id = ?');
                $stmt->execute([$id]);
                $p = $stmt->fetch();
                $p ? responder(200, $p) : responder(404, ['error' => 'No encontrado']);
            }
            $stmt = $pdo->query('SELECT id, nombre, precio, stock FROM productos ORDER BY id');
            responder(200, $stmt->fetchAll());

        case 'POST':
            if ($e = validar($datos)) responder(422, ['errores' => $e]);
            $stmt = $pdo->prepare(
                'INSERT INTO productos (nombre, categoria_id, precio, stock) VALUES (?, ?, ?, ?)'
            );
            $stmt->execute([trim($datos['nombre']), $datos['categoria_id'] ?? null,
                            $datos['precio'], $datos['stock'] ?? 0]);
            responder(201, ['id' => (int) $pdo->lastInsertId()]);

        case 'PUT':
            if (!$id) responder(400, ['error' => 'Falta id']);
            if ($e = validar($datos)) responder(422, ['errores' => $e]);
            $stmt = $pdo->prepare(
                'UPDATE productos SET nombre = ?, categoria_id = ?, precio = ?, stock = ? WHERE id = ?'
            );
            $stmt->execute([trim($datos['nombre']), $datos['categoria_id'] ?? null,
                            $datos['precio'], $datos['stock'] ?? 0, $id]);
            responder(200, ['actualizados' => $stmt->rowCount()]);

        case 'DELETE':
            if (!$id) responder(400, ['error' => 'Falta id']);
            $stmt = $pdo->prepare('DELETE FROM productos WHERE id = ?');
            $stmt->execute([$id]);
            $stmt->rowCount() ? responder(204, null) : responder(404, ['error' => 'No encontrado']);

        default:
            responder(405, ['error' => 'Método no permitido']);
    }
} catch (PDOException $e) {
    error_log($e->getMessage());
    // 1451 = no se puede borrar: el producto está en pedidos (FK)
    if (($e->errorInfo[1] ?? 0) === 1451) {
        responder(409, ['error' => 'El producto tiene pedidos; desactívalo en lugar de borrarlo']);
    }
    responder(500, ['error' => 'Error interno']);
}

Pruébalo con el servidor integrado de PHP:

bash
php -S localhost:8000
curl http://localhost:8000/api/productos.php
curl -X POST http://localhost:8000/api/productos.php \
  -H "Content-Type: application/json" \
  -d '{"nombre":"Bocina portátil","categoria_id":1,"precio":749,"stock":12}'
curl -X DELETE "http://localhost:8000/api/productos.php?id=9"

Versión Node (Express) ​

bash
npm install express mysql2 dotenv
js
// server.js
import express from 'express';
import { pool } from './db.js';

const app = express();
app.use(express.json());

function validar({ nombre, precio, stock = 0 }) {
  const errores = [];
  if (!nombre || !String(nombre).trim()) errores.push('El nombre es obligatorio');
  if (typeof precio !== 'number' || precio < 0) errores.push('Precio inválido');
  if (!Number.isInteger(stock) || stock < 0) errores.push('Stock inválido');
  return errores;
}

// Listar
app.get('/productos', async (req, res, next) => {
  try {
    const [rows] = await pool.execute('SELECT id, nombre, precio, stock FROM productos ORDER BY id');
    res.json(rows);
  } catch (err) { next(err); }
});

// Ver uno
app.get('/productos/:id', async (req, res, next) => {
  try {
    const [rows] = await pool.execute('SELECT * FROM productos WHERE id = ?', [req.params.id]);
    rows.length ? res.json(rows[0]) : res.status(404).json({ error: 'No encontrado' });
  } catch (err) { next(err); }
});

// Crear
app.post('/productos', async (req, res, next) => {
  const errores = validar(req.body);
  if (errores.length) return res.status(422).json({ errores });
  try {
    const { nombre, categoria_id = null, precio, stock = 0 } = req.body;
    const [r] = await pool.execute(
      'INSERT INTO productos (nombre, categoria_id, precio, stock) VALUES (?, ?, ?, ?)',
      [nombre.trim(), categoria_id, precio, stock]
    );
    res.status(201).json({ id: r.insertId });
  } catch (err) { next(err); }
});

// Editar
app.put('/productos/:id', async (req, res, next) => {
  const errores = validar(req.body);
  if (errores.length) return res.status(422).json({ errores });
  try {
    const { nombre, categoria_id = null, precio, stock = 0 } = req.body;
    const [r] = await pool.execute(
      'UPDATE productos SET nombre = ?, categoria_id = ?, precio = ?, stock = ? WHERE id = ?',
      [nombre.trim(), categoria_id, precio, stock, req.params.id]
    );
    r.affectedRows ? res.json({ ok: true }) : res.status(404).json({ error: 'No encontrado' });
  } catch (err) { next(err); }
});

// Borrar
app.delete('/productos/:id', async (req, res, next) => {
  try {
    const [r] = await pool.execute('DELETE FROM productos WHERE id = ?', [req.params.id]);
    r.affectedRows ? res.status(204).end() : res.status(404).json({ error: 'No encontrado' });
  } catch (err) { next(err); }
});

// Manejador de errores centralizado
app.use((err, req, res, next) => {
  console.error(err);
  if (err.code === 'ER_ROW_IS_REFERENCED_2') {
    return res.status(409).json({ error: 'El producto tiene pedidos; desactívalo en lugar de borrarlo' });
  }
  if (err.code === 'ER_DUP_ENTRY') {
    return res.status(409).json({ error: 'Registro duplicado' });
  }
  res.status(500).json({ error: 'Error interno' });
});

app.listen(3000, () => console.log('API en http://localhost:3000'));
bash
node server.js
curl http://localhost:3000/productos

Buenas prácticas de conexión ​

Checklist

  • Credenciales en variables de entorno (.env fuera de Git y fuera de la carpeta pública).
  • Usuario de BD con privilegios mínimos (Usuarios y permisos), nunca root.
  • charset=utf8mb4 en toda conexión.
  • Siempre consultas preparadas; listas blancas para identificadores dinámicos.
  • Operaciones de varios pasos en transacciones con try / catch / finally.
  • En Node, un pool compartido; en PHP, una conexión por petición (o la que gestione tu framework).
  • Errores técnicos al log, mensajes genéricos al usuario.
  • SSL/TLS si la BD está en otro servidor (ssl: { ca: ... } en mysql2, PDO::MYSQL_ATTR_SSL_CA en PDO).

Ejercicios ​

  1. Escribe en PHP una función buscarProductos(PDO $pdo, string $texto, int $pagina) que busque por nombre con LIKE y pagine de 10 en 10, de forma segura.
  2. En Node, crea un endpoint GET /clientes/:id/pedidos que devuelva los pedidos de un cliente con su total.
  3. ¿Qué tiene de malo este código? $pdo->query("DELETE FROM productos WHERE id = " . $_GET['id']);
  4. ¿Qué pasa si en la función crearPedido de Node quitas el finally { conn.release(); }?
Ver solución
php
<?php
// 1
function buscarProductos(PDO $pdo, string $texto, int $pagina): array
{
    $porPagina = 10;
    $offset = max(0, ($pagina - 1) * $porPagina);
    $stmt = $pdo->prepare(
        'SELECT id, nombre, precio FROM productos
         WHERE nombre LIKE :q
         ORDER BY nombre
         LIMIT :limite OFFSET :offset'
    );
    $stmt->bindValue(':q', '%' . $texto . '%');
    $stmt->bindValue(':limite', $porPagina, PDO::PARAM_INT);
    $stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
    $stmt->execute();
    return $stmt->fetchAll();
}
js
// 2
app.get('/clientes/:id/pedidos', async (req, res, next) => {
  try {
    const [rows] = await pool.execute(
      `SELECT p.id, p.fecha, p.estado, SUM(d.cantidad * d.precio_unitario) AS total
       FROM pedidos p
       JOIN detalle_pedido d ON d.pedido_id = p.id
       WHERE p.cliente_id = ?
       GROUP BY p.id, p.fecha, p.estado
       ORDER BY p.fecha DESC`,
      [req.params.id]
    );
    res.json(rows);
  } catch (err) { next(err); }
});
  1. Es vulnerable a inyección SQL (?id=1 OR 1=1 borraría todos los productos), no valida el dato y no usa un método HTTP adecuado (un DELETE disparado por GET puede ejecutarse con solo visitar un enlace). Debe usar prepare + execute([$id]), validar que sea entero y responder solo a peticiones DELETE/POST autenticadas.
  2. Cada pedido deja una conexión "prestada" para siempre. Cuando se alcanzan las 10 del connectionLimit, las siguientes peticiones esperan indefinidamente y la API deja de responder.

Resumen ​

  • PHP: PDO con charset=utf8mb4, ERRMODE_EXCEPTION y EMULATE_PREPARES => false.
  • Node: mysql2/promise con un pool, execute() y decimalNumbers según el caso.
  • Consultas preparadas siempre: los datos nunca se concatenan al SQL.
  • Transacciones con beginTransaction / commit / rollback; en Node, una conexión fija y release() en finally.
  • Traduce los errores de MySQL (1062 duplicado, 1451 FK) a respuestas HTTP claras sin exponer detalles.

Academia Web — aprende, copia y construye.