Tema
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.
| Lenguaje | Driver recomendado | Alternativas |
|---|---|---|
| PHP | PDO (pdo_mysql) | mysqli |
| Node.js | mysql2 (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 clientesCon '; 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 SQLRegla 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ón | Por qué |
|---|---|
charset=utf8mb4 en el DSN | Acentos y emojis correctos (utf8mb4) |
ERRMODE_EXCEPTION | Los errores lanzan excepciones en lugar de fallar en silencio |
FETCH_ASSOC | $fila['nombre'] en lugar de índices numéricos duplicados |
EMULATE_PREPARES => false | Tipos 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 dotenvenv
# .env (agrégalo a .gitignore)
DB_HOST=127.0.0.1
DB_PORT=3306
DB_USER=tienda_app
DB_PASSWORD=secreto
DB_NAME=tiendajs
// 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 dotenvjs
// 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/productosBuenas prácticas de conexión
Checklist
- Credenciales en variables de entorno (
.envfuera de Git y fuera de la carpeta pública). - Usuario de BD con privilegios mínimos (Usuarios y permisos), nunca
root. charset=utf8mb4en 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_CAen PDO).
Ejercicios
- Escribe en PHP una función
buscarProductos(PDO $pdo, string $texto, int $pagina)que busque por nombre conLIKEy pagine de 10 en 10, de forma segura. - En Node, crea un endpoint
GET /clientes/:id/pedidosque devuelva los pedidos de un cliente con su total. - ¿Qué tiene de malo este código?
$pdo->query("DELETE FROM productos WHERE id = " . $_GET['id']); - ¿Qué pasa si en la función
crearPedidode Node quitas elfinally { 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); }
});- Es vulnerable a inyección SQL (
?id=1 OR 1=1borraría todos los productos), no valida el dato y no usa un método HTTP adecuado (unDELETEdisparado por GET puede ejecutarse con solo visitar un enlace). Debe usarprepare+execute([$id]), validar que sea entero y responder solo a peticionesDELETE/POSTautenticadas. - 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_EXCEPTIONyEMULATE_PREPARES => false. - Node: mysql2/promise con un pool,
execute()ydecimalNumberssegún el caso. - Consultas preparadas siempre: los datos nunca se concatenan al SQL.
- Transacciones con
beginTransaction/commit/rollback; en Node, una conexión fija yrelease()enfinally. - Traduce los errores de MySQL (1062 duplicado, 1451 FK) a respuestas HTTP claras sin exponer detalles.