MongoDB + PHP 8.5 · taller paso a paso
La familia documental frente a la SQL que ya conoces: NoSQL, modelado en BSON y el dominio de Pedidos, conectado por PHP 8.5 con la extensión oficial. Teoría completa, paso a paso, de cero a producción.
1 · NoSQL: por qué otra familia además de SQL
Básico ~14 minLos cuatro manuales de la casa (MySQL, PostgreSQL, SQL Server y SQLite) te dieron la familia relacional: tablas, llaves foráneas, JOINS y transacciones. Ese modelo sigue siendo el rey de los datos críticos. Pero nació en 1970, y el mundo cambió: volúmenes enormes, esquemas que evolucionan cada semana, cargas de miles de lecturas por segundo. NoSQL no es "anti-SQL": es la otra familia, con reglas distintas para problemas distintos. Este primer capítulo te da el mapa.
- Explicar qué significa NoSQL (y qué no significa).
- Comparar las cuatro familias NoSQL y cuándo gana cada una.
- Aplicar la "regla de la herramienta correcta" al dominio de Pedidos.
- Reconocer qué problemas de SQL no resuelves con documentos.
NoSQL no significa "sin SQL"
El nombre nace de "Not Only SQL": no SOLO SQL. No hay una guerra
declarada; hay un repositorio de herramientas. Y en el centro de todas ellas
una idea común: flexibilizar el esquema y la forma de consultar.
Mira el dominio que usarás en todo el taller — la tienda webcode:
| Colección (NoSQL) | Contenido en la tienda | En SQL sería… |
|---|---|---|
clientes | 30 clientes: nombre, email, teléfono | tabla clientes |
productos | 40 productos (café, té, chocolate…) | tabla productos |
pedidos | 50 pedidos con sus líneas dentro | tablas pedidos + pedido_detalles |
Fíjate en la última fila: el pedido guarda dentro del mismo documento sus líneas. En SQL, cada línea vive en otra tabla y la reconstruyes con un JOIN. Esa diferencia (datos junto a sus datos) es el corazón del modelo documental, y la exploras en profundidad en los capítulos 5 y 6.
Las cuatro familias NoSQL
| Familia | Unidad de datos | La estrella | Especialidad |
|---|---|---|---|
| Documentos | documento JSON/BSON | MongoDB | datos completos en una pieza, consultados por contenido |
| Clave-valor | par clave-valor | Redis | lectura instantánea por clave (caché, sesiones) |
| Columnas | familia de columnas | Cassandra | escritura masiva distribuida |
| Grafos | nodos y relaciones | Neo4j | relaciones profundas (amigos, rutas, redes) |
MongoDB pertenece a la familia de documentos: es el motor más usado del planeta en esa categoría (según los rankings públicos DB-Engines, única familia en la que el líder no es relacional). El resto de este taller entrena TU ojo documental: cuándo un pedido cabe en un documento y cuándo no.
Cuándo NoSQL gana (y cuándo pierde)
| Escenario | SQL gana | MongoDB gana |
|---|---|---|
| Contabilidad, saldos, facturación | sí (transacciones fuertes) | con cuidado (transacciones multivista existe, cap 8) |
| Catálogo de productos con campos variables | esfuerzo (NULLs, tablas EAV) | sí (esquema flexible natural) |
| Reportes agregados masivos | sí | sí (aggregation pipeline, cap 9) |
| Pedido con sus líneas siempre juntas | JOIN puntual | sí (documento autocontenido) |
| Relaciones tipo grafo (N a M profundas) | varias tablas puente | referencias + $lookup |
| Transacciones críticas con restricciones | sí, maduro | posible pero más joven |
| Los datos "no saben" aún qué forma tendrán | migraciones constantes | sí, modelo evoluciona sin ALTER |
Los 3 momentos en que la tienda diría "SQL"
Para que el mapa sea completo, también debes ver la otra cara. En el dominio Pedidos hay preguntas donde la SQL de los manuales sigue siendo la respuesta natural (y lo verás consolidadas en el capítulo 12):
- Auditar quién cambió algo: la tabla
auditoriay los triggers de la serie clínica son SQL puro. - Restricciones contables: "nunca un monto negativo" con un
CHECK; MongoDB lo resuelve en la capa de aplicación. - JOINs relacionales complejos: "productos vendidos junto a pagos junto a clientes" se expresa más fácil en 3 tablas que en referencias de documentos.
La madurez de una base de datos no se mide en cuántas tareas hace, sino en qué tareas hace sin dolor. SQL maduró 50 años en integridad; MongoDB maduró en escala y flexibilidad. Saber ambas familias te hace el profesional completo: eliges el motor por pregunta, no por costumbre.
# mentalidad del taller, en un bloque
SI la pregunta dominante es: "dame el pedido completo, ya" -> Mongo
SI la pregunta dominante es: "suma saldos cruzando 5 tablas" -> SQL
SI la pregunta todavía no existe: "el esquema cambia cada mes" -> Mongo
REGLA: no mezcles. Documenta la decisión en código, no en rumores.2 · MongoDB y el modelo documental
Básico ~16 minYa sabes por qué existe la familia. Ahora mira al motor: MongoDB
nació en 2009 y su unidad de datos es el documento — no
la fila ni la columna. Aquí aprendes su vocabulario (documento,
colección, _id, BSON), ves el documento de un pedido real
de la tienda y entiendes la diferencia que define todo: esquema flexible
frente a esquema rígido.
- Traducir el vocabulario SQL al vocabulario MongoDB.
- Leer un documento BSON de la tienda (pedido con sus líneas).
- Entender qué es BSON y por qué no es texto JSON.
- Explicar la flexibilidad de esquema con un ejemplo de la tienda.
El vocabulario del modelo documental
| Mundo SQL | Mundo MongoDB | Nota |
|---|---|---|
| motor / SGBD | motor (MongoDB Server) | mismo concepto |
| base de datos | base de datos | agrupa colecciones |
| tabla | colección | no impone columnas |
| fila / registro | documento | un objeto BSON |
| columna / campo | campo (field) | puede tener sub-documentos |
| llave primaria | _id | único, autogenerado |
| JOIN | $lookup / referencia | se evita: documento embebido (cap 5) |
| tipo de dato rígido | tipos BSON flexibles | fechas, números exactos… (cap 3) |
No memorices la tabla: úsala como traducción mientras dure el taller. Con cada capítulo irás dejando de "traducir" y empezando a pensar documental.
El pedido de la tienda como documento
Este es el documento determinista del pedido 3 — cliente 22, estado REGISTRADO, total S/ 296.12, con sus dos líneas adentro. Así lo leerá mongosh (que usa sintaxis JavaScript):
{
"_id": ObjectId("665a5b1f2c8a9b3d0e1f2a03"),
"cliente_id": 22,
"cliente_nombre": "Carmen Soto",
"fecha": ISODate("2026-07-03T10:30:00Z"),
"estado": "REGISTRADO",
"items": [
{ "producto_id": 17, "nombre": "Chocolate 1", "cantidad": 2, "precio_unitario": 94.21 },
{ "producto_id": 30, "nombre": "Miel 6", "cantidad": 3, "precio_unitario": 35.90 }
],
"total": 296.12
}Compara con lo que tendrías que hacer en las tablas relacionales de la serie
PHP: leer pedidos, luego pedido_detalles (con su
JOIN a productos) y luego clientes. Aquí todo llega
en una sola lectura: 2 · 94.21 + 3 · 35.90 = 296.12, con el
cliente reseñado dentro. El documento no tiene filas que unir: es una pieza.
_id es obligatorio y
único en cada colección. Si haces insertOne sin él, MongoDB lo
genera con ObjectId (12 bytes, incluye la fecha). Lo usas a
partir del capítulo 4 y especialmente en PHP (cap 8).BSON: el JSON binario
Los documentos se guardan en BSON (Binary JSON): una codificación binaria del JSON, no texto. Le da a MongoDB dos superpoderes:
- Tipos ricos nativos:
ObjectId, fechas (ISODate), números exactos (NumberDecimalpara dinero SIEMPRE), binario. Esto explica por qué el total va en decimal, no flotante. - Velocidad: se recorre en binario sin "parsear" texto; los índices y agregaciones operan sobre el binario directo (cap 9 y 11).
Dato honesto para tu presupuesto mental: los documentos tienen un límite de tamaño (16 MB por documento) y los campos llevan un orden. En la práctica, un pedido con sus líneas cabe con folgura; pero un historial de 5 años embebido no. Esa frontera la manejas con modelado en el capítulo 5.
Esquema flexible: el ejemplo del campo que nace solo
En SQL, agregar un campo a una tabla con 40.000 filas implica ALTER
TABLE + migraciones + reescritura (los manuales de la casa lo muestran).
En MongoDB, el "campo nuevo" aparece en la próxima escritura:
// La tienda decide añadir "cupon"
db.pedidos.insertOne({
cliente_id: 25,
fecha: new Date("2026-07-18T12:00:00Z"),
estado: "REGISTRADO",
items: [ { producto_id: 9, cantidad: 1, precio_unitario: 68.17 } ],
cupon: "BIENVENIDA-10" // campo nuevo SOLO en este documento
})La colección no se "rompe": los demás pedidos no lo tienen, este sí. Y aquí entra la regla de oro del esquema flexible: flexible no significa desordenado. El desorden paga caro en consultas (cap 4), en índices (cap 11) y en el trabajo en equipo. Flexibilidad es dejar crecer el modelo sin una migración de horas; el orden lo pones tú con convenciones que verás en el capítulo 6 (seed determinista) y en los validadores de PHP.
-- contraste SQL del mismo hecho (serie PHP): antes de agregar "cupon"
ALTER TABLE pedidos ADD COLUMN cupon VARCHAR(50) NULL;
-- y cada entorno (dev / test / prod) debe correr su migraciónLo que MongoDB NO te da (léelo dos veces)
| Facilidad madura en SQL | En MongoDB |
|---|---|
restricciones CHECK / FOREIGN KEY | no existen; la integridad vive en tu capa de aplicación (cap 8) |
| vistas y trigger de auditoría | puedes emular con colecciones + reglas de app; no hay trigers nativos iguales |
| JOIN complejo contra 6 tablas | referencias + $lookup (cap 5 y 9): más simple, menos potencia |
| transacciones ACID multivista (viejo mito: Mongo no transacciona) | existen desde 4.0 (multidocumento). Se usan con mesura (cap 8) |
La lectura limpia: MongoDB resuelve con elegancia documentos autocontenidos y esquema en evolución; te pide disciplina en integridad. Por eso el taller entrena la parte PHP: quién valida, quién escribe la auditoría y quién aprende a decidir. Esa disciplina ES tu trabajo como desarrollador.
_id = identificador único. BSON = JSON
binario con tipos ricos (fechas, ObjectId, Decimal). Un pedido con sus líneas
vive en UNA pieza. Esquema flexible ≠ desorden: evita migraciones de horas
pero exige reglas propias. En el capítulo 3 instalas el motor y lo ves por
fin funcionando.3 · Instalación de MongoDB 8.0 y mongosh
Básico ~20 minLa teoría de los dos primeros capítulos solo se siente con el motor
encendido. Este capítulo instala MongoDB Community 8.0
(la serie Major actual, on-premises) en Windows y Ubuntu, y conecta el
shell oficial mongosh. Usas las instrucciones del manual
oficial de MongoDB; aquí las ejecutas sobre tu máquina y compruebas
cada paso.
- Instalar el servidor
mongod8.0 en Windows y Ubuntu. - Instalar
mongosh(siempre aparte: no viene con el servidor). - Levantar el servicio y comprobar versiones.
- Conectar el shell por primera vez.
Lo que vas a instalar (nombres exactos)
| Componente | Binario | Rol |
|---|---|---|
| MongoDB Server | mongod | el motor; guarda y sirve los documentos BSON |
| MongoDB Shell | mongosh | REPL JavaScript para consultar (reemplaza al viejo mongo) |
| Configuración | mongod.conf / mongod.cfg | puerto (27017), ruta de datos, bind |
Nota que es obligatorio instalar mongosh por separado: el
instalador del servidor NO lo trae. Conecta a MongoDB 7.0 o superior, así que
con tu 8.0 trabajará sin problemas.
Ubuntu 24.04 / 22.04 (apt oficial)
Usa solo el repositorio oficial mongodb-org.
El paquete mongodb del repositorio de Ubuntu no es mantenido por
MongoDB y entra en conflicto; si lo tienes, desinstálalo antes.
# 1. dependencias para importar la clave GPG
sudo apt-get install gnupg curl
# 2. importar la clave publica oficial del canal 8.0
curl -fsSL https://pgp.mongodb.com/server-8.0.asc | \
sudo gpg -o /usr/share/keyrings/mongodb-server-8.0.gpg --dearmor# 3. lista del repositorio. Elige tu version de Ubuntu:
# noble = 24.04 | jammy = 22.04
echo "deb [ arch=amd64,arm64 signed-by=/usr/share/keyrings/mongodb-server-8.0.gpg ] \
https://repo.mongodb.org/apt/ubuntu noble/mongodb-org/8.0 multiverse" | \
sudo tee /etc/apt/sources.list.d/mongodb-org-8.0.list
# 4. actualizar e instalar
sudo apt-get update
sudo apt-get install -y mongodb-org# 5. arrancar el servicio y habilitarlo en el arranque
sudo systemctl daemon-reload
sudo systemctl enable --now mongod
sudo systemctl status mongod --no-pager | head -5Windows 11 (instalador MSI oficial)
# 1. descargar el .msi del Download Center oficial:
# https://www.mongodb.com/try/download/community
# Version: 8.0 · Platform: Windows (x64) · Package: msi
# 2. ejecutar el instalador (Complete) y, en el asistente:
# - marcar "Install MongoD as a Service" (Network Service)
# - rutas por defecto: C:\Program Files\MongoDB\Server\8.0\# 3. (alternativa) sin servicio, abrir cmd COMO ADMINISTRADOR:
cd C:\
md "\data\db"
"C:\Program Files\MongoDB\Server\8.0\bin\mongod.exe" --dbpath "c:\data\db"El servicio se llama MongoDB. Para gestionarlo por consola:
net start MongoDB / net stop MongoDB, o la consola de
Servicios de Windows. Lo que NUNCA hagas con el servidor arrancado es cerrar la
ventana del mongod si lo lanzaste a mano: usa Ctrl+C
para que apague limpio (flush del storage engine).
Instalar mongosh (siempre en paralelo)
# WINDOWS: .msi desde https://www.mongodb.com/try/download/shell
# (agregar su carpeta bin a PATH al instalar)
# UBUNTU: mismo repositorio anterior
sudo apt-get install -y mongodb-mongosh# comprobar todo: una linea por binario
mongod --version
mongosh --versionmongod por
primera vez y mongosh te responde ECONNREFUSED,
espera 2–5 segundos: el motor está inicializando WiredTiger. Vuelve a
intentar. Fuera de eso, mongod escucha por defecto en
127.0.0.1:27017 y solo acepta conexiones locales — perfecto
para el taller, jamás lo expongas sin autenticación (cap 11).Primer viaje al shell
mongoshtest> show dbs
admin 72.00 KiB
config 60.00 KiB
local 72.00 KiB
test> db.version()
8.0.30
test>Las bases admin, config y local son
internas del motor (existen en la práctica). db.version() te
confirma la instalación. En el próximo capítulo creamos la base
tienda y cargamos el dominio Pedidos.
mongod; shell =
mongosh (se instala aparte). Ubuntu: repositorio oficial
mongodb-org-8.0, nunca el paquete mongodb.
Windows: asistente MSI + servicio "MongoDB". Verificación: mongod
--version y mongosh --version. Por defecto escucha solo
en localhost:27017. Próximo paso: CRUD real sobre Pedidos.4 · CRUD básico en mongosh sobre Pedidos
Básico ~18 minEl motor está encendido; ahora le hablas. Este capítulo ejecuta las
cuatro operaciones clásicas — crear, leer, actualizar y
eliminar — directamente en mongosh, sobre el
dominio de la tienda. Aquí nace tu intuición porque los resultados son
inmediatos y visibles.
- Crear la base
tienday usar colecciones. - Insertar uno y muchos documentos.
- Leer con filtros y proyectar campos.
- Actualizar y eliminar con precisión.
Crear la base y la colección
En MongoDB la base y la colección se crean de forma implícita al guardar el
primer documento. No hay CREATE DATABASE ni CREATE TABLE:
use tienda
// respuesta: switched to db tienda
db.clientes.insertOne({
documento: "20260001",
nombre: "Ana Rojas",
correo: "ana.rojas@webcode.dev",
telefono: "987 654 321",
creado: ISODate("2026-06-01T12:00:00Z")
}){ acknowledged: true,
insertedId: ObjectId('665b2c1f8ea34d0e1f9a2b10') }Dos detalles importantes. Primero, acknowledged: true confirma
que el servidor recibió y guardó la escritura. Segundo, aunque no pasas
_id, MongoDB lo generó por ti: es un ObjectId de 12
bytes (fecha + contador de máquina + contador). Esa es tu llave primaria.
Insertar varios: insertMany
Los pedidos de la serie traen sus líneas dentro. Inserta tres pedidos
deterministas de la tienda con su array items embebido:
db.pedidos.insertMany([
{ _id: 1, cliente_id: 8, estado: "PAGADO", fecha: ISODate("2026-07-01T10:00:00Z"),
items: [ { producto: "Cafe 1", cantidad: 2, precio_unitario: 42.13 } ],
total: 84.26 },
{ _id: 2, cliente_id: 15, estado: "PAGADO", fecha: ISODate("2026-07-02T11:30:00Z"),
items: [ { producto: "Miel 1", cantidad: 1, precio_unitario: 30.25 } ],
total: 30.25 },
{ _id: 3, cliente_id: 22, estado: "REGISTRADO", fecha: ISODate("2026-07-03T09:15:00Z"),
items: [ { producto: "Chocolate 1", cantidad: 2, precio_unitario: 94.21 },
{ producto: "Miel 6", cantidad: 3, precio_unitario: 35.90 } ],
total: 296.12 }
])Usamos _id numérico (1, 2, 3) a propósito: te facilita seguir
los ejemplos. En producción casi siempre lo dejas autogenerado. El pedido 3
coincide con el documento que leíste en el capítulo 2: cliente 22, dos líneas,
total 296.12.
Leer: find y sus filtros
// todos los pedidos
db.pedidos.find()
// filtro de igualdad
db.pedidos.find({ estado: "PAGADO" })
// rango de valores
db.pedidos.find({ total: { $gte: 100 } })
// logico: dos condiciones unidas (AND implicito)
db.pedidos.find({ estado: "PAGADO", total: { $gt: 50 } })Los operadores $gte, $gt, $lt,
$ne vienen del MQL (MongoDB Query Language). Los usas en las
consultas de find y, desde PHP, en el capítulo 9.
Proyectar y contar
// solo campos utiles; _id se excluye con 0
db.pedidos.find(
{ estado: "REGISTRADO" },
{ _id: 0, cliente_id: 1, total: 1, estado: 1 }
)
// cuantos hay
db.pedidos.countDocuments({ estado: "PAGADO" })
db.pedidos.countDocuments()
Actualizar: updateOne con $set
El operador $set modifica el valor de un campo sin reemplazar
todo el documento. Es el equivalente a un UPDATE ... SET
focalizado:
db.pedidos.updateOne(
{ _id: 3 },
{ $set: { estado: "PAGADO" } }
)
{ acknowledged: true,
matchedCount: 1,
modifiedCount: 1 }matchedCount dice cuántos documentos coincidieron con el
filtro; modifiedCount, cuántos cambiaron. Son dos números
distintos y te conviene leerlos siempre (en PHP los necesitas para decidir
respuestas HTTP).
Eliminar: deleteOne
db.pedidos.deleteOne({ _id: 2 })
// respuesta: { acknowledged: true, deletedCount: 1 }
Ojo con un mito frecuente: los filtros se ejecutan igual en
update y delete. Un updateOne sin filtro o con filtro
demasiado ancho puede pisar muchos documentos; por eso la serie te enseña a
pensar primero el "dónde" y después el "qué" (cap 8 lo repite en PHP).
insertOne /
insertMany. Leer: find(filtro, proyeccion).
Actualizar: updateOne + $set. Borrar:
deleteOne. La colección y la base se crean al primer
documento. El valor devuelto siempre confirma la operación
(acknowledged, insertedId,
matched/modified/deletedCount). Próximo: modelado — cuándo
embebido y cuándo referencia.5 · Embebido versus referencia en documentales
Básico ~18 minEl 90% del éxito en MongoDB se decide en UNA pregunta: ¿este dato va dentro del documento o **referenciado por su _id**? En SQL la respuesta es casi siempre "tabla aparte con FK". En documentales no: aquí cada opción cambia tus consultas, tus índices y tu fatiga diaria. Este capítulo te da la regla de decisión y la aplica al dominio de Pedidos.
- Definir documento embebido (sub-documento) y documento referenciado.
- Conocer la regla de oro del modelado documental.
- Aplicarla al binomio pedido + líneas.
- Conocer cuándo sí necesitas referencias (y $lookup).
Los dos caminos
| Aspecto | Embebido | Referencia |
|---|---|---|
| Dónde vive | dentro del documento padre | en otra colección, unida por _id |
| Lectura | todo junto, una sola consulta | requiere segunda consulta o $lookup |
| Escritura | se modifica el padre completo | se modifica por separado |
| Último dato ganador | el que se lee SIEMPRE con el padre | el que cambia solo, o es enorme, o se comparte |
| Cuándo huye | si crece sin límite o no se lee junto | si cada acceso pide el padre completo |
Embebido: el pedido con sus líneas
En la tienda, la pregunta dominante del negocio es: "dame el pedido con sus productos". Eso se lee miles de veces al día y siempre completo. Por eso desde el capítulo 2 viste los items DENTRO del pedido:
db.pedidos.findOne({ _id: 1 })
{
_id: 1,
cliente_id: 8,
estado: "PAGADO",
fecha: ISODate("2026-07-01T10:00:00Z"),
items: [
{ producto: "Cafe 1", cantidad: 2, precio_unitario: 42.13 }
],
total: 84.26
}Una sola lectura trae todo lo que la interfaz necesita. En la serie SQL
esto exigía JOIN de pedidos + pedido_detalles +
productos.
Referencia: el cliente y el producto
Pero mira el cliente y el producto: viven su propia vida. El cliente cambia
su teléfono y eso NO es "parte del pedido"; aparece en cientos de pedidos.
El producto cambia su precio y ese cambio NO debe reescribir todas las líneas
históricas. Por eso en los ejemplos el pedido guarda cliente_id
y los items guardan el nombre y el precio capturado — eso es
una "foto" del momento de la venta:
db.productos.insertOne({ _id: 41, nombre: "Cafe 9", precio: 42.13, stock: 20 })
// el pedido no guarda el precio "vivo" del producto:
// guarda el precio QUE SE VENDE, aunque mañana el catalogo cambieLa llave para no romperte la cabeza: una referencia une dos documentos que se actualizan en momentos distintos. El pedido referencia al cliente; la línea luce al producto.
Cuándo te hace falta $lookup
Una referencia se materializa con $lookup en la agregación:
"dame los pedidos y junta los datos del cliente". MongoDB 5.0 permitió juntar
con sub-consultas, pero el patrón de este taller es el más común:
db.pedidos.aggregate([
{ $match: { estado: "PAGADO" } },
{
$lookup: {
from: "clientes",
localField: "cliente_id",
foreignField: "_id",
as: "cliente"
}
},
{ $unwind: "$cliente" },
{ $project: { "cliente.nombre": 1, total: 1 } }
])
La agregación completa es el capítulo 9; aquí solo ves que existe y por qué: porque cuando genuinamente necesitas el dato compartido, MongoDB te lo da, aunque cuesta más que un JOIN. Y ese "cuesta más" es la señal de que mejor modelaste con referencia solo dónde corresponde.
La decisión aplicada a la tienda
| Relación | Decisión | Por qué |
|---|---|---|
| pedido → líneas | EMBEBIDO | se leen siempre juntas y son pocas |
| pedido → cliente | REFERENCIA (cliente_id) | el cliente es autónomo y cambia solo |
| línea → producto | REFERENCIA + "foto" | nombre y precio capturados en la venta |
| cliente → historial de pedidos | NUNCA embebido | crece sin límite; se consulta filtrado |
| usuario → sesiones | embebido o colección aparte | tamaño acotado; decide por volumen |
$lookup. La "foto" del precio
en la línea protege tu histórico. Límite de 16 MB como frontera técnica.
Ya puedes moldear el seed completo del capítulo 6.6 · Seed determinista de la tienda en BSON
Básico ~20 minLos manuales SQL de la casa cargan la tienda_orm con
datos deterministas: en cualquier máquina obtienes los MISMOS números.
Este capítulo construye el seed equivalente en BSON: 40
productos, 30 clientes, 50 pedidos, con las líneas embebidas
según la regla del capítulo 5. Usas mongosh como scripting
(Node) para generarlo de forma repetible, y lo cierras verificando
totales contra la serie SQL.
- Generar el catálogo de 40 productos con precios deterministas.
- Insertar 30 clientes.
- Construir 50 pedidos con sus líneas embebidas.
- Verificar que los totales coinciden con la serie SQL.
Contrato de datos (idéntico a la serie SQL)
| Colección | Documentos | Contrato |
|---|---|---|
productos | 40 | _id 1..40, nombre, precio, stock |
clientes | 30 | _id 1..30, nombre, correo, telefono |
pedidos | 50 | _id 1..50, cliente_id, estado, fecha, items[], total |
Los valores derivan de las MISMAS fórmulas que tienda_orm:
precios con patrón modular, estados cíclicos PAGADO/PAGADO/REGISTRADO/
REGISTRADO/ANULADO y un cliente por pedido también determinista. Así, si
corres el seed en tu máquina, obtienes exactamente los totales que muestra
este capítulo — ni un céntimo de más.
Catálogo de 40 productos
Los precios siguen la fórmula precio(n). Con mongosh
puedes iterar con un for de JavaScript:
const categorias = ["Cafe", "Te", "Chocolate", "Miel", "Accesorio"];
const seedProductos = [];
for (let n = 1; n <= 40; n++) {
const nombre = categorias[Math.floor((n - 1) / 8)] + " " + ((n - 1) % 8 + 1);
seedProductos.push({
_id: n,
nombre,
precio: 5 + ((n * 37) % 90) + ((n * 13) % 100) / 100,
stock: 10 + ((n * 7) % 90)
});
}
db.productos.insertMany(seedProductos);
db.productos.countDocuments(); // 40Comprueba el determinismo con un punto de control:
db.productos.find({ _id: { $in: [1, 9, 17, 25, 33] } },
{ _id: 1, nombre: 1, precio: 1 })[
{ _id: 1, nombre: "Cafe 1", precio: 42.13 },
{ _id: 9, nombre: "Te 1", precio: 68.17 },
{ _id: 17, nombre: "Chocolate 1", precio: 94.21 },
{ _id: 25, nombre: "Miel 1", precio: 30.25 },
{ _id: 33, nombre: "Accesorio 1", precio: 56.29 }
]30 clientes
const seedClientes = [];
for (let n = 1; n <= 30; n++) {
seedClientes.push({
_id: n,
nombre: "Cliente " + n,
correo: "cliente" + n + "@webcode.dev",
telefono: "900" + String(10000000 + n).slice(1)
});
}
db.clientes.insertMany(seedClientes);
db.clientes.countDocuments(); // 3050 pedidos con líneas embebidas
Cada pedido lleva su items adentro (regla del capítulo 5) y
un cliente_id determinista. La fecha es generada dentro de julio
2026 y el total se calcula al insertar:
const seedPedidos = [];
for (let p = 1; p <= 50; p++) {
const items = [];
for (let k = 1; k <= 2; k++) {
const prod = (p + k * 13) % 40 + 1;
const cant = 1 + ((p * k * 7) % 5);
const precio = 5 + ((prod * 37) % 90) + ((prod * 13) % 100) / 100;
items.push({ producto_id: prod, cantidad: cant, precio_unitario: precio });
}
const estado = ["PAGADO", "PAGADO", "REGISTRADO", "REGISTRADO", "ANULADO"][(p - 1) % 5];
const total = items.reduce((acc, it) => acc + it.cantidad * it.precio_unitario, 0);
seedPedidos.push({
_id: p,
cliente_id: (p * 7) % 30 + 1,
estado,
fecha: new Date("2026-07-" + String((p % 28) + 1).padStart(2, "0") + "T" + ((p % 12) + 8) + ":0:00Z"),
items,
total: Math.round(total * 100) / 100
});
}
db.pedidos.insertMany(seedPedidos);
db.pedidos.countDocuments(); // 50Math.round(total * 100) /
100 es intencional: elimina la basura de coma flotante
(ej. 296.1200000000001 → 296.12). Es la disciplina "dinero en
decimal", obligatoria desde el capítulo 2. En producción, el dinero se
guarda con NumberDecimal (BSON) o en céntimos (int).Verificación contra la serie SQL
Ahora la prueba que da valor a todo: los totales deben coincidir con
tienda_orm. Compáralos:
db.pedidos.countDocuments({ estado: "PAGADO" }); // 20
db.pedidos.countDocuments({ estado: "REGISTRADO" }); // 20
db.pedidos.countDocuments({ estado: "ANULADO" }); // 10
db.pedidos.aggregate([
{ $match: { estado: "PAGADO" } },
{ $group: { _id: null, total: { $sum: "$total" } } }
])
// [{ _id: null, total: 9067.55 }] <- coincide con la serie SQL
db.pedidos.find({ _id: 3 },
{ _id: 1, cliente_id: 1, estado: 1, total: 1, items: 1 })
// { _id: 3, cliente_id: 22, estado: "REGISTRADO", total: 296.12, items: [...] }
db.pedidos.find({ cliente_id: 8 }).sort({ _id: 1 })
// los pedidos del cliente 8 en la serie SQL son 1 y 31| Verificación | tienda_orm (SQL) | BSON |
|---|---|---|
| Facturado PAGADO | S/ 9067.55 | S/ 9067.55 |
| Total general 50 pedidos | S/ 15210.50 | S/ 15210.50 |
| Pedido 3 | cli 22 · REGISTRADO · 296.12 | idéntico |
| Pedidos del cliente 8 | ids 1 y 31 | ids 1 y 31 |
_id deterministas 1..N. Pedido embebido con items y
total calculado. Dinero redondeado a 2 decimales (y en producción, decimal
exacto). La verificación por totals es automática con una agregación.
Próxima etapa: ¡PHP 8.5!7 · La extensión mongodb y la librería PHP
Intermedio ~20 minHasta aquí todo corrió en mongosh. Ahora la tienda
cobra vida dentro de tu aplicación: la conectas con PHP
8.5. MongoDB ofrece dos piezas complementarias y este
capítulo las distingue, las instala y hace la primera conexión.
- Extensión
ext-mongodb(C) vs libreríamongodb/mongodb(Composer). - Instalar la extensión con PIE (reemplazo de PECL).
- Crear la conexión con
MongoDB\Client. - Comparar el Manager (extensión) con la Librería.
Dos capas: la extensión y la librería
extensión ext-mongodb | librería mongodb/mongodb | |
|---|---|---|
| ¿Qué es? | driver en C (PHP modulo) | capa PHP sobre la extensión |
| Se instala con | PIE mongodb/mongodb-extension | Composer require mongodb/mongodb |
| API que expone | Manager, BSON raw, comandos | Client, Collection, resultados |
| Rol | cableado al servidor | la API amable que usarás |
Vas a instalar ambas. La regla de la casa: en tu aplicación llamas a la
Librería; la extensión queda debajo, invisible. El MongoDB\Client
que conoces en todos los ejemplos pertenece a la Librería y usa la extensión
por debajo.
Instalar la extensión con PIE
PIE (PHP Installer for Extensions) es el instalador oficial que reemplaza a PECL (deprecado desde la serie 1.x). Instala y habilita la extensión en un solo comando:
pie install mongodb/mongodb-extensionEl comando compila el módulo con el PHP configurado (te pregunta la versión si tienes varias). Al terminar, comprueba que quedó cargada:
php -m | grep mongodb
# respuesta esperada: mongodb
php -r 'echo MongoDB\Driver\Version::get() . PHP_EOL;'
# p.ej.: 2.4.0¿Necesitas una extensión por versión de PHP? Sí: el driver
2.x requiere PHP ≥ 8.1 (tu 8.5 cumple) y cada PHP tiene su módulo compilado.
Si cambiases de versión, reinstalas con el mismo pie apuntando a
esa.
La librería con Composer
Con la extensión activa, crea tu proyecto y añade la Librería:
mkdir -p ~/tienda && cd ~/tienda
composer require mongodb/mongodbComposer instalará el paquete mongodb/mongodb (la librería).
Su primer archivo de entrada es src/Client.php. El
composer.json puede declarar la dependencia de la extensión para
que composer install la detecte:
{
"require": {
"php": "^8.5",
"mongodb/mongodb": "^2.4",
"ext-mongodb": "^2.4"
}
}Primera conexión con MongoDB\Client
Un script mínimo que conecta y lista las bases:
<?php declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
$client = new MongoDB\Client('mongodb://127.0.0.1:27017');
foreach ($client->listDatabases() as $db) {
printf("%-12s %8s docs\n", $db->getName(), number_format($db->getSizeOnDisk()));
}
admin 24576 bytes
config 24576 bytes
local 24576 bytes
tienda 131072 bytes
declare(strict_types=1) es obligatorio en la casa: convierte
coerciones silenciosas en TypeError (mira cómo PHP 8.5 lo
aprovecha en migración). $client es vago: no se conecta hasta la
primera operación real, y luego reutiliza la conexión. Conectarse NO es un
costo por request.
Manager vs Librería (cuándo cada una)
La extensión también expone MongoDB\Driver\Manager, que habla
el protocolo wire en crudo. La Librería es una capa que te da el CRUD
cómodo:
| Necesidad | Usa |
|---|---|
| Aplicación normal (CRUD, este taller) | MongoDB\Client (Librería) |
| Comandos brutos, transacciones avanzadas | Manager (extensión) |
| GridFS, sesión, aggregate cómodo | Librería |
En el 98% del taller (y de tu día a día) usarás la Librería. La extensión vive debajo; la tocas cuando necesitas algo que la Librería no envuelve.
pecl install
mongodb, traduce: pie install
mongodb/mongodb-extension. El detalle de Windows (binarios
precompilados) lo cubre la documentación de la extensión.pie);
Librería = API PHP (composer require mongodb/mongodb).
Conexión: new MongoDB\Client('mongodb://127.0.0.1:27017').
Vaga hasta la primera operación real. Versiones objetivo: PHP ≥ 8.1 para el
driver 2.x. Próximo capítulo: CRUD real desde PHP 8.5.8 · CRUD desde PHP 8.5 sobre Pedidos
Intermedio ~25 minCapítulo 4 usó mongosh para el CRUD; ahora el mismo
repaso vive en PHP 8.5 con la Librería. La mecánica es
idéntica — mismos filtros, mismos operadores — pero el resultado llega
en objetos con tipos, y cada operación te devuelve un objeto resultado
que conviene usar.
- Seleccionar base y colección con la Librería.
- Insertar (
insertOne/insertMany) y capturar_id. - Leer con
find/findOney recorrer cursores. - Actualizar y eliminar leyendo los contadores de resultado.
Seleccionar base y colección
<?php declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
$client = new MongoDB\Client('mongodb://127.0.0.1:27017');
$db = $client->selectDatabase('tienda');
$ped = $db->selectCollection('pedidos');La colección pedidos ya existe (capítulo 6) y sus documentos
usan _id numérico. Mantén esa convención: facilita los ejemplos.
Insertar y capturar los IDs
$resume = $ped->insertOne([
'cliente_id' => 5,
'estado' => 'REGISTRADO',
'fecha' => new MongoDB\BSON\UTCDateTime(),
'items' => [
['producto_id' => 17, 'cantidad' => 1, 'precio_unitario' => 94.21],
],
'total' => 94.21,
]);
$id = $resume->getInsertedId();
printf("id insertado: %s\n", var_export($id, true));UTCDateTime es el tipo BSON de fecha que respeta el capítulo 2;
getInsertedId() te devuelve el _id (aquí un
int, porque así lo definimos). Nunca asumas el valor: lo lees del
resultado.
Inserción masiva con insertMany:
$docs = [];
for ($n = 1; $n <= 3; $n++) {
$docs[] = [
'cliente_id' => 5,
'estado' => 'REGISTRADO',
'fecha' => new MongoDB\BSON\UTCDateTime(),
'items' => [
['producto_id' => 1, 'cantidad' => 1, 'precio_unitario' => 42.13],
],
'total' => 42.13,
];
}
$result = $ped->insertMany($docs);
printf("insertados: %d, ids: %s\n",
$result->getInsertedCount(),
implode(',', $result->getInsertedIds())
);Leer con find y cursores
$cursor = $ped->find(
['estado' => 'PAGADO'],
['limit' => 3]
);
foreach ($cursor as $doc) {
printf("#%d cli=%d S/%.2f\n",
$doc['_id'], $doc['cliente_id'], $doc['total']);
}
$uno = $ped->findOne(['_id' => 3]);
printf("pedido 3: %s S/%.2f\n", $uno['estado'], $uno['total']);find() devuelve un Cursor (stream); lo recorres
con foreach. findOne te da el documento o
null. Necesitas el nombre del cliente? Con referencia modelada,
una segunda consulta a clientes es lo normal (o un
aggregate $lookup, capítulo 9):
$cli = $client->selectCollection('tienda', 'clientes')
->findOne(['_id' => $uno['cliente_id']]);
echo $cli['nombre'], "\n";Actualizar con $set
$upd = $ped->updateOne(
['_id' => 51],
['$set' => ['estado' => 'PAGADO']]
);
printf("coinciden: %d, modificados: %d\n",
$upd->getMatchedCount(),
$upd->getModifiedCount()
);Los dos contadores son distintos y útiles: matchedCount dice si
el documento EXISTE; modifiedCount si CAMBIÓ (0 si el estado ya
era PAGADO). Ese contraste alimenta la doble validación de la casa en la capa
PHP (capítulo 12 lo retoma).
Eliminar con deleteOne
$del = $ped->deleteOne(['_id' => 51]);
printf("eliminados: %d\n", $del->getDeletedCount());insertOne devuelve
InsertOneResult; updateOne, UpdateResult;
deleteOne, DeleteResult. Lee SIEMPRE sus métodos
(getInsertedId, getMatchedCount,
getModifiedCount, getDeletedCount). Ese es el
equivalente del affected rows de la serie SQL.Hydración a objetos (lógica en el dominio)
Regla de la casa: la lógica NO vive en arrays sueltos. Empaca el documento
en un objeto de dominio Pedido:
final class Pedido {
public function __construct(
public readonly int $id,
public readonly int $clienteId,
public readonly string $estado,
public readonly array $items,
) {}
public function total(): float {
$t = 0.0;
foreach ($this->items as $it) {
$t += $it['cantidad'] * $it['precio_unitario'];
}
return round($t, 2);
}
public static function fromDocument(array $d): self {
return new self(
(int) $d['_id'],
(int) $d['cliente_id'],
(string) $d['estado'],
(array) $d['items'],
);
}
}
$doc = $ped->findOne(['_id' => 3]);
$pedido = Pedido::fromDocument($doc);
printf("pedido %d de cli %d: S/%.2f (%s)\n",
$pedido->id, $pedido->clienteId, $pedido->total(), $pedido->estado);El dominio llama, el controlador decide (cuándo 422 vs 200) y la vista pinta. MongoDB te da el documento crudo; tú le das forma. Ese es el patrón que dejarás listo para el ejercicio del capítulo 10.
MongoDB\Client → base →
colección. insertOne/insertMany/find/findOne/updateOne(deleteOne
con la misma sintaxis del shell. Resultados con métodos descriptivos; si no
los lees, pierdes información. UTCDateTime para fechas.
Hydración a objetos de dominio. Próximo: filtros, proyecciones y la
agregación aggregate.9 · Consultas avanzadas y agregaciones en PHP
Intermedio ~25 minEl CRUD del capítulo 8 resolvió operaciones simples. Ahora vienen las consultas que de verdad sirven al negocio: filtros con operadores, proyecciones, orden, y el pipeline de agregación que convierte 50 pedidos en "facturado, top clientes, top productos" — sin mover datos a PHP.
- Filtros con operadores MQL ($gt, $gte, $in…).
- Proyección de campos y opciones de
find. - Ordenar, límites y conteo.
- Pipeline
aggregate: $match, $group, $sort, $lookup.
Filtros con operadores
Los operadores MQL ($gt, $gte, $lt, $in, $ne…) funcionan exacto en PHP.
De nuevo la colección pedidos sembrada en el capítulo 6:
$promo = $ped->find(
['estado' => 'PAGADO', 'total' => ['$gte' => 500]],
['sort' => ['total' => -1]]
);
foreach ($promo as $d) {
printf("cli#%d S/%.2f\n", $d['cliente_id'], $d['total']);
}Proyección: qué traes al PHP
Con $projection traes solo lo que necesitas (menos bytes,
menos memoria, más rápida la API):
$resumen = $ped->find(
['total' => ['$gte' => 500]],
[
'projection' => ['_id' => 1, 'cliente_id' => 1, 'total' => 1],
]
);
foreach ($resumen as $d) {
printf("#%d cli#%d S/%.2f\n", $d['_id'], $d['cliente_id'], $d['total']);
}Dato determinista: de los 50 pedidos del seed, los que superan S/500 con estado PAGADO son pocos y concretos. El reporte del capítulo 10 los vuelve a usar — revisa que coincidan contándolos aquí primero.
Conteo sin traer datos
$n = $ped->countDocuments(['estado' => 'PAGADO']);
$total = $ped->countDocuments();
printf("PAGADO: %d de %d pedidos\n", $n, $total);
// top 3 por monto
$top = $ped->find([], ['sort' => ['total' => -1], 'limit' => 3]);
foreach ($top as $d) {
printf("#%d S/%.2f\n", $d['_id'], $d['total']);
}countDocuments es el conteo correcto en la Librería moderna
(reemplaza al antiguo count, deprecado). El conteo lee el índice
y no materializa documentos.
El pipeline aggregate
La agregación procesa en el servidor: lo que fuera del pipeline era "traer 50 pedidos y sumar en PHP", ahora es "que MongoDB agrupe y sume". Empieza con $match y $group para el total facturado:
$stats = $ped->aggregate([
['$match' => ['estado' => 'PAGADO']],
['$group' => [
'_id' => null,
'total' => ['$sum' => '$total'],
'n' => ['$sum' => 1],
]],
]);
foreach ($stats as $s) {
printf("%d pedidos PAGADO · S/%.2f\n", $s['n'], $s['total']);
}Refina el reporte por cliente (el $lookup trae el nombre — colección referenciada, capítulo 5) con orden:
$byCliente = $ped->aggregate([
['$match' => ['estado' => 'PAGADO']],
['$group' => [
'_id' => '$cliente_id',
'gasto' => ['$sum' => '$total'],
]],
['$lookup' => [
'from' => 'clientes',
'localField' => '_id',
'foreignField' => '_id',
'as' => 'cli',
]],
['$unwind' => '$cli'],
['$sort' => ['gasto' => -1]],
['$limit' => 3],
]);
foreach ($byCliente as $r) {
printf("%-14s S/%.2f\n", $r['cli']['nombre'], $r['gasto']);
}El pipeline es una lista de etapas en orden: FILTRA → AGRUPA → JUNTA →
DESENROLLA → ORDENA → LIMITA. Cada etapa recibe lo que produce la anterior.
Esto reemplaza, en SQL de la casa, a un GROUP BY con JOIN — y la semántica
$lookup/$unwind es exactamente tu JOIN + desempaque.
Presupuesto: cuántas consultas lanzaste
| Bloque | Consultas a MongoDB | Resultado |
|---|---|---|
| $match+$group | 1 | suma y n (server-side) |
| $lookup reporte | 1 (pipeline) | top 3 con nombre |
| CRUD capítulo 8 (findOne+findOne clientes) | 2 | pedido + cliente |
| Si hubieras traído 50 docs y sumado en PHP | 1 + N+1 | antipatrón |
Regla de la casa: cuenta las consultas por página. Aggregate comprime el
reporte a UNA consulta; nunca recorras un cursor para sumar en PHP lo que
$sum resuelve en el servidor.
find.
projection para traer solo lo útil. countDocuments
y opciones sort/limit. aggregate procesa
server-side con $match→$group→$lookup→$unwind→$sort→$limit. Une sin traer
todo: es tu JOIN. Cuenta tus queries. Próximo: el reporte integrador
"Pedidos del mes".10 · Reporte integrador: Pedidos de julio 2026
Intermedio ~35 minLlegó la prueba que junta todo: un reporte mensual real. "Dame el facturado de julio 2026, el top de clientes y los productos que más se vendieron" — y hazlo con el presupuesto de tres consultas (regla de la casa), sin arrastrar los 50 pedidos a PHP. El contrato de datos es el que conoces: fechas en julio, estados PAGADO, 50 pedidos.
- Filtrar por rango de fechas con $gte/$lt.
- Reporte server-side con aggregate + N+1 cero.
- Entregar JSON limpio como API.
- Contar y justificar las consultas usadas.
1. Total facturado de julio 2026
<?php declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
$client = new MongoDB\Client('mongodb://127.0.0.1:27017');
$ped = $client->selectDatabase('tienda')->selectCollection('pedidos');
$ini = new MongoDB\BSON\UTCDateTime((new DateTime('2026-07-01'))->getTimestamp() * 1000);
$fin = new MongoDB\BSON\UTCDateTime((new DateTime('2026-08-01'))->getTimestamp() * 1000);
$facturado = $ped->aggregate([
['$match' => [
'estado' => 'PAGADO',
'fecha' => ['$gte' => $ini, '$lt' => $fin],
]],
['$group' => [
'_id' => null,
'total' => ['$sum' => '$total'],
'n' => ['$sum' => 1],
]],
])->toArray();
$f = $facturado[0] ?? ['total' => 0.0, 'n' => 0];
printf("julio 2026 · PAGADO: %d pedidos · S/%.2f\n", $f['n'], $f['total']);julio 2026 · PAGADO: 20 pedidos · S/9067.55Coincide con la serie SQL (facturado determinista de tienda_orm):
esa es la señal de que el pipeline está bien. La fecha se acota con
$gte/$lt (semirrango), no $lte en el último día — el patrón eterno
de los rangos.
2. Top 3 clientes del mes
$top = $ped->aggregate([
['$match' => ['estado' => 'PAGADO', 'fecha' => ['$gte' => $ini, '$lt' => $fin]]],
['$group' => ['_id' => '$cliente_id', 'gasto' => ['$sum' => '$total']]],
['$lookup' => ['from' => 'clientes', 'localField' => '_id', 'foreignField' => '_id', 'as' => 'cli']],
['$unwind' => '$cli'],
['$sort' => ['gasto' => -1]],
['$limit' => 3],
])->toArray();
foreach ($top as $r) {
printf("%-16s S/%.2f\n", $r['cli']['nombre'], $r['gasto']);
}Cliente 30 S/1301.56
Cliente 20 S/1281.96
Cliente 15 S/1193.26Una sola consulta (el pipeline) — sin N+1. El $lookup trae el
nombre de la colección referenciada; el $unwind lo desempaqueta.
Misma técnica que el capítulo 9, ahora dentro del reporte.
3. Top productos vendidos (líneas embebidas)
Aquí brilla el modelado documental: el producto está en cada línea, así
que desenrollamos $items y agrupamos por id:
$prod = $ped->aggregate([
['$match' => ['estado' => 'PAGADO', 'fecha' => ['$gte' => $ini, '$lt' => $fin]]],
['$unwind' => '$items'],
['$group' => [
'_id' => '$items.producto_id',
'cantidad' => ['$sum' => '$items.cantidad'],
'ingreso' => ['$sum' => ['$multiply' =>
['$items.cantidad', '$items.precio_unitario']]],
]],
['$sort' => ['ingreso' => -1]],
['$limit' => 3],
])->toArray();
foreach ($prod as $r) {
printf("prod#%d · x%d · S/%.2f\n", $r['_id'], $r['cantidad'], $r['ingreso']);
}4. Presupuesto final del reporte
| Sección | Tipo | Consultas |
|---|---|---|
| Total facturado | aggregate | 1 |
| Top clientes | aggregate + $lookup | 1 |
| Top productos | aggregate + $unwind de $items | 1 |
| TOTAL por petición | — | 3 |
| Cómo NO debe hacerse | traer 50 docs y sumar en PHP | N+1 |
json_encode con
JSON_UNESCAPED_UNICODE. El contrato JSON/BSON del capítulo 2 te
evita el problema de "convertir filas" que sufren los manuales relacionales.5. El reporte comparado con la serie SQL
| MongoDB (este capítulo) | Serie SQL de la casa | |
|---|---|---|
| Total facturado | $match+$group+$sum | SUM(total) WHERE estado GROUP BY |
| Top clientes | $lookup (clientes) | JOIN clientes |
| Detalle por producto | $unwind de items embebido | JOIN pedido_detalles |
| Número de queries | 3 pipelines | 3 consultas con JOINs |
| Forma | documento anidado | filas normales |
El reporte es el mismo, la semántica es la misma: agrupar, sumar, unir, ordenar. Lo que cambia es el molde (JSON anidado vs filas) y el vocabulario ($group vs GROUP BY, $lookup vs JOIN, $unwind vs desempaque de detalle).
11 · Índices, explain y seguridad en MongoDB
Intermedio ~25 minUn CRUD que funciona y un reporte correcto ya son algo. Ahora
vamos por el profesionalismo: que las consultas sean rápidas
(índices verificados con explain()) y que tu aplicación no
sea un colador (la NoSQL injection no existe en SQL,
así que hay que aprenderla aquí).
- Crear índices simples, compuestos y únicos.
- Probar tus consultas con
explain("executionStats"). - Conocer los tres patrones de NoSQL injection.
- Aplicar el blindaje sistemático de la casa.
Por qué un índice
Sin índice, MongoDB hace un COLLSCAN (recorre documento por documento). Con el índice correcto, hace IXSCAN sobre una estructura ordenada y trae solo lo que pides. En un catálogo de 40 productos no se nota; en 40 millones se nota hasta el presupuesto.
$productos->createIndex(['precio' => 1]); // simple ascendente
$pedidos->createIndex(['estado' => 1, 'fecha' => -1]); // compuesto
$clientes->createIndex(['correo' => 1], ['unique' => true]); // únicoReglas para crear índices:
- Índice simple para filtros de un campo.
- Índice compuesto (varios campos en orden) para filtros + orden combinados; el orden de los campos importa.
- Índice único para imponer unicidad de negocio (documento, correo…).
- No indexes todo: cada índice suma costo en cada escritura.
Probando con explain()
Antes de optimizar, mide. explain("executionStats") te dice
qué plan ganó y cuántos documentos escaneó:
// en mongosh
db.pedidos.find({ estado: "PAGADO", fecha: { $gte: ISODate("2026-07-01") } })
.explain("executionStats"){ ...,
"queryPlanner": { "winningPlan": { "stage": "IXSCAN" } },
"executionStats": {
"nReturned": 20,
"totalKeysExamined": 20,
"totalDocsExamined": 20
}
}Lee los tres contadores:
nReturned: cuántos devolvió (20 PAGADO en julio, el setup determinista).totalKeysExamined: cuántas claves del índice recorrió.totalDocsExamined: cuántos documentos abrió.
La marca de una consulta bien indexada: los tres números coinciden. Si
totalDocsExamined > nReturned, el índice no cubre el filtro y
escaneaste de más. En la librería PHP el control es
$collection->explain($operation), pero el hábito de la casa es
ensayar primero en mongosh con la misma consulta.
_id ya trae índice
único automático. En clientes.correo el índice único NO solo
acelera: si el correo debe ser único de negocio, crearlo aquí (como el
UNIQUE de la serie SQL) es lo que lo hace cumplir — MongoDB
rechazará el segundo documento con el mismo correo.NoSQL injection: el enemigo que SQL no tenía
En SQL se inyecta por texto escapable; aquí se inyecta por
estructura. Cuando PHP recibe campo[$ne]=valor,
lo convierte en ['campo' => ['$ne' => 'valor']] y eso va
derechito a tu filtro. Tres patrones a conocer:
// PATRON 1 · operador inyectado (bypass de login)
$_POST = ['usuario' => ['$ne' => 'x']]; // si no lo blindas, cambia la semántica
// PATRON 2 · regex inyectado (exfiltración)
$_POST = ['usuario' => ['$regex' => '^a']]; // prueba caracter por caracter
// PATRON 3 · $where con JavaScript del servidor (lo peor)
// db.usuarios.find({ $where: "this.clave == " + input }) // NUNCAEl blindaje de la casa, sistemático y doble:
// 1. cast explícito: el input SIEMPRE termina siendo el tipo que el filtro espera
$usuario = (string) ($_POST['usuario'] ?? '');
// 2. el filtro NO recibe el input crudo: recibe el valor ya tipado
$doc = $usuarios->findOne(['usuario' => $usuario]);
// 3. sanitizar claves que empiecen con $
function rechazarOperadores(array $input): array {
foreach (array_keys($input) as $k) {
if (str_starts_with((string) $k, '$')) {
throw new InvalidArgumentException('operador no permitido');
}
}
return $input;
}Además, dos decisiones que cierran la puerta:
- Nunca uses
$wherecon input del usuario. El JavaScript del servidor ($where, $function) es deprecado en MongoDB 8.0 y desactivable consecurity.javascriptEnabled: falseenmongod.conf. En el taller no lo usas en absoluto. - Límite siempre en las APIs:
limitpara que una respuesta no vacíe la colección entera.
Checklist de seguridad del taller
| Práctica | Estado en este taller |
|---|---|
| Bind a 127.0.0.1 (mongod.conf) | por defecto (cap 3) |
| Autenticación habilitada | se activa antes de exponer (cap 12) |
| Cast + filtros tipados | con el patrón (string) |
| Sin $where con input | no lo usamos |
| javascriptEnabled: false | recomendado en producción |
| Límite en respuestas | limit en cada API (cap 12) |
| Índices únicos | correo, documento (cap 11) |
explain("executionStats") y comparar nReturned /
totalDocsExamined. NoSQL injection = inyección por estructura: cast, filtros
tipados y rechazo de claves $. Lo de $where con
JavaScript: prohibido. Con esto ya puedes blindar el proyecto del capítulo
12.12 · Proyecto integrador y cierre del taller
Intermedio ~40 minÚltimo capítulo: montas una API completa de Pedidos con PHP 8.5 + MongoDB que aplica TODO lo aprendido — modelo documental, CRUD, agregaciones, índices y seguridad. Al final, repasas las tres preguntas del modelado y recibes la graduación.
- Montar la API
GET /api/pedidoscon filtro y límite. - Registrar un pedido con su total calculado (transacción documental).
- Aplicar seguridad real: cast + rechazo de operadores.
- Las tres preguntas del modelado documental.
1. Conexión y utilidades
<?php declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
$client = new MongoDB\Client('mongodb://127.0.0.1:27017');
$db = $client->selectDatabase('tienda');
$ped = $db->selectCollection('pedidos');
function json(bool $ok, int $code, array $data): never {
http_response_code($code);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => $ok, ...$data], JSON_UNESCAPED_UNICODE);
exit;
}
function rechazarOperadores(array $input): array {
foreach (array_keys($input) as $k) {
if (str_starts_with((string) $k, '$')) {
throw new InvalidArgumentException('operador no permitido');
}
}
return $input;
}2. GET /api/pedidos con filtros seguros
$params = rechazarOperadores($_GET);
$filtro = [];
if (isset($params['estado'])) {
$filtro['estado'] = (string) $params['estado'];
}
if (isset($params['desde'], $params['hasta'])) {
$filtro['fecha'] = [
'$gte' => new MongoDB\BSON\UTCDateTime(
(new DateTime((string) $params['desde']))->getTimestamp() * 1000),
'$lte' => new MongoDB\BSON\UTCDateTime(
(new DateTime((string) $params['hasta']))->getTimestamp() * 1000),
];
}
$cursor = $ped->find($filtro, [
'limit' => min(100, max(1, (int) ($params['limite'] ?? 20))),
'sort' => ['fecha' => -1],
]);
$out = [];
foreach ($cursor as $d) {
$out[] = [
'id' => $d['_id'],
'cliente_id' => $d['cliente_id'],
'estado' => $d['estado'],
'total' => $d['total'],
];
}
json(true, 200, ['data' => $out, 'total' => count($out), 'consultas' => 1]);Observa las tres defensas activas: el rechazarOperadores
(sanitiza claves $), cada valor se castea a su tipo con
(string), y el limit se acota a [1,100]. El filtro
solo permite estado, desde, hasta,
limite; todo lo demás se ignora («allowlist por
omisión»).
3. POST /api/pedidos (registro con total)
$body = json_decode(file_get_contents('php://input'), true);
$body = rechazarOperadores(is_array($body) ? $body : []);
$clienteId = (int) ($body['cliente_id'] ?? 0);
$items = (array) ($body['items'] ?? []);
if ($clienteId < 1 || count($items) === 0) {
json(false, 422, ['error' => 'cliente_id e items requeridos']);
}
$total = 0.0;
foreach ($items as $it) {
$total += (float) $it['cantidad'] * (float) $it['precio_unitario'];
}
$total = round($total, 2);
$result = $ped->insertOne([
'cliente_id' => $clienteId,
'estado' => 'REGISTRADO',
'fecha' => new MongoDB\BSON\UTCDateTime(),
'items' => $items,
'total' => $total,
]);
json(true, 201, ['id' => $result->getInsertedId(), 'total' => $total]);curl -X POST http://localhost:8000/api/pedidos \
-H 'Content-Type: application/json' \
-d '{"cliente_id":8,"items":[
{"producto_id":17,"cantidad":2,"precio_unitario":94.21},
{"producto_id":30,"cantidad":3,"precio_unitario":35.90}]}'
# {"ok":true,"id":51,"total":296.12}El servidor calcula el total desde las líneas (lógica en el
dominio) y devuelve el _id — el total NUNCA viaja en el request
(regla de la casa: el cliente no decide montos). 296.12 coincide con el patrón
del pedido 3.
4. Índice y caché de colección
$ped->createIndex(['estado' => 1, 'fecha' => -1]);
$db->selectCollection('clientes')->createIndex(['correo' => 1], ['unique' => true]);
// verificación
foreach ($ped->listIndexes() as $i) {
echo $i->getName(), ': ', json_encode($i), PHP_EOL;
}Ese createIndex en (estado, fecha) convierte tu
GET más usado en un IXSCAN directo (capítulo 11). Corre
curl otra vez y mira el antes/después con
explain("executionStats").
5. Las tres preguntas del modelado documental
| Pregunta | Respuesta | En este taller |
|---|---|---|
| 1º ¿Se lee junto a su padre? | Sí → embebido | items dentro de pedido |
| 2º ¿Crece sin límite? | Sí → referencia | historial del cliente nunca embebido |
| 3º ¿Cambia de vida solo? | Sí → referencia + «foto» | producto: nombre+precio capturados |
Con esas tres respuestas modelas cualquier dominio documental: pedidos, catálogos, carritos, chats, eventos. Y cuando dudes, recuerda el límite de 16 MB por documento.
6. Puente y graduación
La serie SQL terminó con un puente a MongoDB este taller. Aquí el puente regresa: lo que aprendiste a modelar en BSON lo puedes volcar a schemas (MariaDB/Laravel Eloquent) y viceversa. La lógica de negocio no cambia: qué es embebible, qué es referencia, cuántas consultas cuesta cada respuesta. Solo cambia el molde.
| Concepto SQL serie | Equivalente MongoDB (este taller) |
|---|---|
| Tabla + filas | colección + documentos |
| JOIN | referencia + $lookup |
| pedido_detalles | items embebidos |
| UNIQUE / CHECK | índice único / validación de aplicación |
| GROUP BY + SUM | aggregate $group + $sum |
| transacción / commit | insertOne atómico (y sesiones multi-doc) |
explain() de una consulta contra el EXPLAIN de tu
motor SQL favorito: verás el mismo oficio. ¡Enhorabuena, lista tu
primera familia documental!rechazarOperadores. El total se calcula en el
dominio, nunca viaja en el request. Índice compuesto (estado, fecha) para el
GET más usado. Tres preguntas del modelado: embebido/referencia/foto. El
puente MySQL↔Mongo es de mentalidad, no de caja. Fin del taller: revisa el
anexo y celebra.