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.

12 capítulos Bootstrap 5.3 Modo claro / oscuro Optimizado para móvil MongoDB 8.0 · PHP 8.5
12
Capítulos
60+
Bloques de código
5
Partes del taller
SQL
Base recomendada
Cómo usar este tutorial: sigue los capítulos en orden (el índice está en el menú si lees desde el móvil). Cada capítulo tiene teoría, ejemplos ejecutables y puntos clave al final. Practica cada ejemplo: es la única vía para dominar cualquier tema.

1 · NoSQL: por qué otra familia además de SQL

Básico ~14 min

Los 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 tiendaEn SQL sería…
clientes30 clientes: nombre, email, teléfonotabla clientes
productos40 productos (café, té, chocolate…)tabla productos
pedidos50 pedidos con sus líneas dentrotablas 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

FamiliaUnidad de datosLa estrellaEspecialidad
Documentosdocumento JSON/BSONMongoDBdatos completos en una pieza, consultados por contenido
Clave-valorpar clave-valorRedislectura instantánea por clave (caché, sesiones)
Columnasfamilia de columnasCassandraescritura masiva distribuida
Grafosnodos y relacionesNeo4jrelaciones 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)

EscenarioSQL ganaMongoDB gana
Contabilidad, saldos, facturaciónsí (transacciones fuertes)con cuidado (transacciones multivista existe, cap 8)
Catálogo de productos con campos variablesesfuerzo (NULLs, tablas EAV)sí (esquema flexible natural)
Reportes agregados masivossí (aggregation pipeline, cap 9)
Pedido con sus líneas siempre juntasJOIN puntualsí (documento autocontenido)
Relaciones tipo grafo (N a M profundas)varias tablas puentereferencias + $lookup
Transacciones críticas con restriccionessí, maduroposible pero más joven
Los datos "no saben" aún qué forma tendránmigraciones constantessí, 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 auditoria y 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 min

Ya 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 SQLMundo MongoDBNota
motor / SGBDmotor (MongoDB Server)mismo concepto
base de datosbase de datosagrupa colecciones
tablacolecciónno impone columnas
fila / registrodocumentoun objeto BSON
columna / campocampo (field)puede tener sub-documentos
llave primaria_idúnico, autogenerado
JOIN$lookup / referenciase evita: documento embebido (cap 5)
tipo de dato rígidotipos BSON flexiblesfechas, 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.

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 (NumberDecimal para 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ón

Lo que MongoDB NO te da (léelo dos veces)

Facilidad madura en SQLEn MongoDB
restricciones CHECK / FOREIGN KEYno existen; la integridad vive en tu capa de aplicación (cap 8)
vistas y trigger de auditoríapuedes emular con colecciones + reglas de app; no hay trigers nativos iguales
JOIN complejo contra 6 tablasreferencias + $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.

3 · Instalación de MongoDB 8.0 y mongosh

Básico ~20 min

La 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 mongod 8.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)

ComponenteBinarioRol
MongoDB Servermongodel motor; guarda y sirve los documentos BSON
MongoDB ShellmongoshREPL JavaScript para consultar (reemplaza al viejo mongo)
Configuraciónmongod.conf / mongod.cfgpuerto (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 -5

Windows 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 --version

Primer viaje al shell

mongosh
test> 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.

4 · CRUD básico en mongosh sobre Pedidos

Básico ~18 min

El 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 tienda y 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).

5 · Embebido versus referencia en documentales

Básico ~18 min

El 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

AspectoEmbebidoReferencia
Dónde vivedentro del documento padreen otra colección, unida por _id
Lecturatodo junto, una sola consultarequiere segunda consulta o $lookup
Escriturase modifica el padre completose modifica por separado
Último dato ganadorel que se lee SIEMPRE con el padreel que cambia solo, o es enorme, o se comparte
Cuándo huyesi crece sin límite o no se lee juntosi 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 cambie

La 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ónDecisiónPor qué
pedido → líneasEMBEBIDOse leen siempre juntas y son pocas
pedido → clienteREFERENCIA (cliente_id)el cliente es autónomo y cambia solo
línea → productoREFERENCIA + "foto"nombre y precio capturados en la venta
cliente → historial de pedidosNUNCA embebidocrece sin límite; se consulta filtrado
usuario → sesionesembebido o colección apartetamaño acotado; decide por volumen

6 · Seed determinista de la tienda en BSON

Básico ~20 min

Los 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ónDocumentosContrato
productos40_id 1..40, nombre, precio, stock
clientes30_id 1..30, nombre, correo, telefono
pedidos50_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(); // 40

Comprueba 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(); // 30

50 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(); // 50

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óntienda_orm (SQL)BSON
Facturado PAGADOS/ 9067.55S/ 9067.55
Total general 50 pedidosS/ 15210.50S/ 15210.50
Pedido 3cli 22 · REGISTRADO · 296.12idéntico
Pedidos del cliente 8ids 1 y 31ids 1 y 31

7 · La extensión mongodb y la librería PHP

Intermedio ~20 min

Hasta 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ía mongodb/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-mongodblibrería mongodb/mongodb
¿Qué es?driver en C (PHP modulo)capa PHP sobre la extensión
Se instala conPIE mongodb/mongodb-extensionComposer require mongodb/mongodb
API que exponeManager, BSON raw, comandosClient, Collection, resultados
Rolcableado al servidorla 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-extension

El 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/mongodb

Composer 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:

NecesidadUsa
Aplicación normal (CRUD, este taller)MongoDB\Client (Librería)
Comandos brutos, transacciones avanzadasManager (extensión)
GridFS, sesión, aggregate cómodoLibrerí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.

8 · CRUD desde PHP 8.5 sobre Pedidos

Intermedio ~25 min

Capí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/findOne y 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());

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.

9 · Consultas avanzadas y agregaciones en PHP

Intermedio ~25 min

El 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

BloqueConsultas a MongoDBResultado
$match+$group1suma y n (server-side)
$lookup reporte1 (pipeline)top 3 con nombre
CRUD capítulo 8 (findOne+findOne clientes)2pedido + cliente
Si hubieras traído 50 docs y sumado en PHP1 + N+1antipatró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.

10 · Reporte integrador: Pedidos de julio 2026

Intermedio ~35 min

Llegó 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.55

Coincide 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.26

Una 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ónTipoConsultas
Total facturadoaggregate1
Top clientesaggregate + $lookup1
Top productosaggregate + $unwind de $items1
TOTAL por petición3
Cómo NO debe hacersetraer 50 docs y sumar en PHPN+1

5. El reporte comparado con la serie SQL

MongoDB (este capítulo)Serie SQL de la casa
Total facturado$match+$group+$sumSUM(total) WHERE estado GROUP BY
Top clientes$lookup (clientes)JOIN clientes
Detalle por producto$unwind de items embebidoJOIN pedido_detalles
Número de queries3 pipelines3 consultas con JOINs
Formadocumento anidadofilas 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 min

Un 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]); // único

Reglas 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.

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 })  // NUNCA

El 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 $where con input del usuario. El JavaScript del servidor ($where, $function) es deprecado en MongoDB 8.0 y desactivable con security.javascriptEnabled: false en mongod.conf. En el taller no lo usas en absoluto.
  • Límite siempre en las APIs: limit para que una respuesta no vacíe la colección entera.

Checklist de seguridad del taller

PrácticaEstado en este taller
Bind a 127.0.0.1 (mongod.conf)por defecto (cap 3)
Autenticación habilitadase activa antes de exponer (cap 12)
Cast + filtros tipadoscon el patrón (string)
Sin $where con inputno lo usamos
javascriptEnabled: falserecomendado en producción
Límite en respuestaslimit en cada API (cap 12)
Índices únicoscorreo, documento (cap 11)

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/pedidos con 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

PreguntaRespuestaEn este taller
1º ¿Se lee junto a su padre?Sí → embebidoitems dentro de pedido
2º ¿Crece sin límite?Sí → referenciahistorial 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 serieEquivalente MongoDB (este taller)
Tabla + filascolección + documentos
JOINreferencia + $lookup
pedido_detallesitems embebidos
UNIQUE / CHECKíndice único / validación de aplicación
GROUP BY + SUMaggregate $group + $sum
transacción / commitinsertOne atómico (y sesiones multi-doc)