Este archivo proporciona orientación a Claude Code (claude.ai/code) cuando trabaja con el código en este repositorio.
Es una aplicación web PHP/MariaDB que corre sobre XAMPP (Apache + MariaDB). No tiene paso de compilación ni gestor de paquetes — todas las dependencias están incluidas en libs/ (TCPDF) y public/ (AdminLTE, Bootstrap, jQuery, etc.).
Stack: PHP 7.4+, MariaDB/MySQL, Apache con mod_rewrite, JavaScript vanilla (ES6+), Bootstrap 4 / AdminLTE 3.
Asegurarse de que XAMPP esté corriendo con Apache y MariaDB, luego navegar a:
http://localhost/FlowPOS/
Iniciar/detener los servicios de XAMPP:
sudo /opt/lampp/lampp start
sudo /opt/lampp/lampp stopImportar el esquema (solo la primera vez o al resetear):
mysql -u root flowpos < database/schema.sqlEl nombre de la base de datos es flowpos según lo configurado en .env.
Establecer permisos en los directorios de subida:
chmod 755 public/uploads/productos/ public/uploads/clientes/ public/uploads/usuarios/Ajustar los valores en .env — config/env.php lo carga automáticamente:
APP_NAME=FlowPOS
APP_VERSION=1.0.0
APP_CURRENCY=Bs
DB_HOST=localhost
DB_NAME=flowpos
DB_USER=root
DB_PASS=
APP_URL=http://localhost/FlowPOS/
TIMEZONE=America/La_Paz
DEBUG=true
APP_URL debe terminar con / y coincidir con la ruta URL real. Se usa en toda la app para redirecciones y enlaces a assets mediante la variable global $URL.
APP_VERSION define la versión funcional vigente del proyecto y debe mantenerse alineada con CHANGELOG.md.
El proyecto usa versionado semántico (MAJOR.MINOR.PATCH) y documenta cambios en CHANGELOG.md.
- Mantener
APP_VERSIONsincronizado con la última versión publicada. - Registrar cambios relevantes en la sección
Unreleasedy versionarlos al publicar.
La aplicación sigue un patrón MVC personalizado sin router. No hay un front controller que despache rutas — las vistas y controladores se incluyen directamente mediante require/include de PHP.
index.php— punto de entrada autenticado; redirige a la vista de dashboard específica según el rol en$_SESSION['usuario_cargo']views/layouts/session.php— incluido en prácticamente todas las páginas; inicia la sesión PHP, carga.env, define$URLy provee las funciones de autenticación (isAuthenticated(),requireLogin(),requireRole(),getCurrentUser()) y CSRF (generateCSRFToken(),verifyCSRFToken(),csrfField(),csrfMetaTag(),getRequestCSRFToken(),requireCSRF(),getSafeRedirectBack())views/layouts/header.php— incluido después de session; llama arequireLogin(), instanciaAuthorizationService, renderiza el<head>y navbar de AdminLTEviews/layouts/footer.phpyviews/layouts/mensajes.php— cierran el HTML y renderizan mensajes flash de$_SESSION['mensaje']con SweetAlert2
| Directorio | Propósito |
|---|---|
config/ |
env.php (carga .env), config.php (retorna array de config), conexion.php (wrapper PDO Singleton) |
models/ |
Clases de acceso a datos (una por entidad); cada una instancia Conexion::getInstance() |
controllers/ |
Clases controlador y scripts de acción por módulo (ej. controllers/ventas/VentaController.php, controllers/ventas/crear_venta.php) |
views/ |
Archivos de vista PHP organizados por módulo; incluyen controladores y modelos directamente según necesiten |
services/ |
AuthorizationService.php (verificación de permisos por nombre/ID), ImagenService.php (subida y eliminación de imágenes), RateLimiterService.php (rate limiting de login por cuenta/IP), literal.php (conversión número a palabras) |
libs/ |
Librerías de terceros empaquetadas — solo TCPDF para generación de PDFs |
public/js/modules/ |
Archivos JavaScript por módulo (un subdirectorio por módulo) |
public/js/core/common-utils.js |
Utilidades JS compartidas |
public/uploads/ |
Imágenes subidas por usuarios (productos/, clientes/, usuarios/) |
Conexion en config/conexion.php es un Singleton que lee desde config/config.php (que a su vez lee .env). Se accede en cualquier lugar mediante:
require_once __DIR__ . '/../../config/conexion.php';
$pdo = Conexion::getInstance()->getConnection();Dos niveles de control de acceso:
- Por rol (
requireRole(['administrador', 'supervisor'])) — aplicado a nivel de página vía sesión - Por permiso (
AuthorizationService::tienePermiso()/tienePermisoNombre()) — verificaciones granulares por acción, almacenadas en las tablaspermisoypermisousuario. Los administradores evitan todas las verificaciones de permisos.
Roles de usuario: administrador, supervisor, vendedor.
Todo endpoint de escritura bajo controllers/*/ que reciba POST debe invocar requireCSRF(); (definido en views/layouts/session.php) como primera línea tras los require_once. Corta la ejecución con 403 JSON (AJAX) o redirect + mensaje flash (form tradicional) si el token falta o es inválido; no destruye la sesión activa.
- Formularios tradicionales: incluir
<?= csrfField() ?>dentro de cada<form method="post">. - AJAX jQuery: el token viaja automático vía header
X-CSRF-Tokengracias al$.ajaxSetupglobal enpublic/js/core/common-utils.js, que lo lee del meta tag<?= csrfMetaTag() ?>emitido enviews/layouts/header.php. - AJAX con FormData (uploads):
formData.append('csrf_token', csrfToken)explícito, leyendo el token del meta tag. - Acciones que mutan estado invocadas antes por GET (desactivar, cambiar estado, anular, cerrar sesión): usar
submitCsrfForm(action, fields)decommon-utils.js, que construye y envía un formulario oculto por POST con el token incluido — nuncawindow.location.hrefa un endpoint de escritura.
empresa,sucursal— estructura empresa/sucursal (preparada para uso multi-tenant futuro)usuarios— usuarios concargo(rol) eidsucursalpermiso,permisousuario— sistema de permisos granularproducto,categoria— catálogo de productos con control de stockcliente— registro de clientesventa,detalleventa,pagoventa— ventas con líneas de detalle y métodos de pago mixtos (efectivo, tarjeta, QR, transferencia)compra,detallecompra— ingreso de mercadería/inventariosesionusuario— registro de auditoría de sesionesintento_login— registro de intentos de login (éxito/fallo) para rate limiting por cuenta e IP
Los dashboards por rol (views/dashboard/dashboard*.php) no reciben datos precargados desde el controlador de vista — el JS del módulo hace fetch() a un endpoint dedicado en controllers/dashboard/ (ej. get_general_dashboard_data.php) que devuelve JSON. Cada sección del payload se condiciona con AuthorizationService::tienePermisoNombre(): si el usuario no tiene el permiso, la clave se omite del JSON (no se envía vacía ni con datos parciales) y el frontend muestra "Sin acceso a esta información" en su lugar. Al agregar una sección nueva a un dashboard, seguir este mismo patrón de permiso-por-sección en el endpoint, no en la vista.
CSS compartido entre los 3 dashboards por rol vive en public/css/modules/dashboard/dashboard.css — evitar duplicar <style> inline por vista; agregar ahí lo que aplique a más de un rol.
header.php/footer.php cargan DataTables y Select2 por defecto. Si una vista no usa alguna, declarar antes de include_once 'header.php':
$skip_datatables = true; // Sin tabla; evita cargar DataTables/pdfmake/vfs_fonts (~2.8MB)
$skip_select2 = true; // Sin Select2Antes de marcar skip_select2, confirmar que ningún <select class="select2"> dependa de initializeSelect2() — la ausencia de un JS de módulo no es evidencia suficiente. Si header.php se incluye antes de saber el contexto (p. ej. index.php despachando por rol), declarar los flags ahí mismo.
Cualquier <select> nuevo en una vista debe llevar la clase select2 y su inicialización debe pasar siempre por initializeSelect2() (public/js/core/common-utils.js), nunca reimplementando $(...).select2({...}) inline — el fix de altura/responsive de Select2 vive centralizado en public/css/core/common.css (no duplicarlo en CSS de módulo). Si el select vive dentro de un modal Bootstrap, inicializar en el evento shown.bs.modal con { dropdownParent: $('#modalId') } (si no, el dropdown se renderiza mal/cortado). Antes de cerrar cualquier módulo nuevo o auditado, correr grep -rn "<select" views/ | grep -v select2 — mismo tipo de checklist que el drawCallback de tooltips. Bug real encontrado 2026-08-26: views/categorias/index.php y views/empresa/index.php tenían $skip_select2 = true sin darse cuenta de que ya usaban <select> en sus modales (quedaban sin estilizar); y sucursales/index.php/empresa/index.php tenían un <select> de Estado sin la clase select2 en absoluto.
.sidebar-sticky— columna lateral fija al hacer scroll (position: sticky,staticbajo 767.98px)..card-outline-tabs .nav-link:not(.active)— color info en pestañas inactivas de cualquier card con tabs (vistas "show"), sin CSS por módulo..dataTables_wrapper .pagination .page-item:not(.active):not(.disabled) .page-link— oscurece el#007bffde la paginación de DataTables a#0056b3(contraste WCAG AA);:not()deja intactas la página activa (blanco sobre primary) y Anterior/Siguiente deshabilitados. Global para todos los DataTables.common.cssymodule_stylesversionados por query string (?v=<?= $appVersion ?>) igual quemodule_scripts, para evitar caché obsoleta.
AdminLTE sobrescribe todas las variantes .alert-* de Bootstrap con fondo sólido oscuro y texto blanco (ej. .alert-info = #17a2b8 con #fff → 3.04:1, falla WCAG AA en texto de cuerpo; .alert-secondary = #6c757d). Para un callout informativo legible usar las variantes .alert-default-info / .alert-default-warning / etc. de AdminLTE, que conservan el look claro original de Bootstrap (#d1ecf1 con #0c5460 → ~7:1). No pelear con overrides de color sobre .alert-* ni agregar border-left grueso (baja calidad). Encontrado 2026-08-30 en views/permisos/index.php; barrido posterior migró todos los alert-info de views/usuarios/* y views/productos/show.php a alert-default-info, y luego (2026-08-30) los alert-warning/alert-success/alert-danger de productos/show.php, ventas/show.php y compras/show.php a sus variantes alert-default-*. No quedan .alert-* sólidas de AdminLTE en views/ fuera de este patrón.
Se asignan en $_SESSION['mensaje'] (cadena de texto) y $_SESSION['icono'] (ícono de SweetAlert2: success, error, warning, info). Los renderiza views/layouts/mensajes.php.
Los recibos se generan con TCPDF desde libs/TCPDF-main/. Los PDFs se emiten marcados como "sin valor fiscal".
json_encode(array_filter(...))sin reindexar produce un objeto JS, no un array:array_filter()preserva las claves originales del array; si estas dejan de ser secuenciales desde 0,json_encode()serializa el resultado como objeto ({"2":...,"5":...}) en vez de array ([...]). Cualquier JS que reciba ese dato víadata-*/.data()y llame.forEach()/.map()sobre él falla conTypeError: x.forEach is not a function. Envolver siempre conarray_values()antes dejson_encode()cuando el array pasó porarray_filter()(verviews/ventas/create.php,data-productos).- Tablas anchas (5+ columnas) en vistas con inputs por celda no deben depender solo de
.table-responsive: el scroll horizontal oculta columnas fuera de la vista sin indicarlo, mala UX en móvil. Para tablas editables tipo carrito (views/ventas/create.php,#tabla-productos), usar un breakpoint (@media (max-width: 767.98px)) que convierta<tr>/<td>adisplay: blockcondata-labelen cada<td>para mostrar la etiqueta de columna como pseudo-elemento::before, formando tarjetas apiladas en vez de forzar scroll lateral. - Ownership check debe ir antes de
include_once header.php:header.phpya emite HTML (sidebar, navbar) antes de retornar. Si un check de propiedad (idusuariode la sesión vs. dueño del registro) se coloca después de incluirheader.phpy falla, elheader('Location: ...')correspondiente no aborta la ejecución con un redirect real — PHP no puede enviar cabeceras HTTP una vez que ya se envió cuerpo de respuesta — y el resultado es una página en blanco en vez de la redirección con mensaje flash esperada (bug detectado enviews/compras/show.php, corregido en 1.1.5 moviendo la obtención del registro y el ownership check antes delinclude_once, igual que enviews/ventas/show.php). El acceso queda bloqueado igualmente (no hay fuga de datos), pero es una regresión de UX que debe evitarse: todoheader('Location: ...')condicionado a una verificación (permiso, existencia, propiedad) debe ejecutarse antes de incluirheader.php. - Patrón "escape-at-storage" (deuda técnica conocida):
sanitizarDatos()en los modelos (Venta,Cliente,Producto,Usuario,Empresa,Sucursal) aplicahtmlspecialchars()a todos los strings al guardar, no al mostrar. Por eso las vistas (show.php,recibo.phpde ventas, etc.) deben interpolar esos campos sin volver a escaparlos — hacerlo produce doble escape visible (<b>literal en vez de texto legible). Verificado enviews/ventas/show.phpyviews/ventas/recibo.phpconphp -ly prueba manual en navegador (payload<script>alert(1)</script>en observación: se muestra como texto plano, sin duplicar entidades). Riesgo conocido: si algún endpoint futuro escribe en estas tablas sin pasar porsanitizarDatos(), ese dato quedaría sin escapar y se renderizaría crudo → XSS almacenado. Mientras este patrón siga vigente, cualquier INSERT/UPDATE nuevo sobre estas tablas debe pasar por elsanitizarDatos()del modelo correspondiente; no agregarhtmlspecialchars()en las vistas como "defensa extra" sin antes confirmar si el dato ya viene escapado. Migrar todo el proyecto a "escapar al mostrar" (quitar el escape desanitizarDatos()y agregarlo en cada punto de salida) es la solución de fondo, pero es un refactor transversal pendiente de decisión, no parte de ninguna auditoría de módulo individual. cargode usuario nunca debe aceptarse crudo del$_POSTsin verificar quién lo envía:UsuarioController::guardar()yactualizar()tomabancargodel formulario sin whitelist ni chequeo de rol, permitiendo que cualquier usuario con el permiso granularusuarios(no soloadministrador) se auto-asignara o asignara a otrocargo=Administrador— escalada de privilegios (encontrado y corregido 2026-08-11 en el módulousuarios). Regla general: cualquier campo que determine el nivel de acceso de una cuenta (rol, cargo, permisos) debe validarse contraAuthorizationService::esAdministrador($_SESSION['usuario_id'])(o el chequeo de jerarquía equivalente) en el controlador, nunca confiar en que el<select>de la vista oculte la opción — el POST directo la evade igual. Corolario descubierto en la auditoría de 2026-08-11: el fix original solo validaba el cargo nuevo enviado en el POST, dejando sin protección la modificación de una cuenta que ya era Administrador (un no-admin podía enviarcargo=Vendedorpara degradarla, o cambiarle la contraseña, evadiendo el check porque el valor nuevo ya no era "Administrador"; mismo hueco encambiarEstadoUsuario()para desactivar cuentas admin). Regla completa: validar ambos lados — el cargo nuevo solicitado Y el cargo actual del registro objetivo — antes de permitir cualquier modificación (UsuarioController::actualizar()ycambiarEstadoUsuario()).UsuarioController::cambiarEstadoUsuario($id, $estado_actual)espera el estado actual (antes del cambio), no el nuevo: internamente calcula$nuevo_estado = $estado_actual == 1 ? 0 : 1, es decir, hace el toggle él mismo.public/js/modules/usuarios/index-usuarios.jslo invoca correctamente (estado: estadoActual), peropublic/js/modules/usuarios/show-usuario.js(botón "Activar/Desactivar" del detalle de usuario) enviaba el estado ya invertido en el cliente (estado: estadoActual == 1 ? 0 : 1), causando un doble-toggle: el backend volvía a invertirlo y el estado en BD quedaba sin cambios, aunque el mensaje flash indicaba lo contrario ("Usuario desactivado correctamente" sin queestadocambiara). Verificado con consulta directa ausuarios.estadoantes/después del clic (bug encontrado y corregido 2026-08-11 durante prueba end-to-end del módulo usuarios). Regla general: cualquier endpoint que "invierte" un estado debe documentar explícitamente si espera el valor actual o el deseado, y todos los llamadores JS deben usar la misma convención — verificar ambos emisores (index-usuarios.jsyshow-usuario.js) cuando se toque este endpoint de nuevo.- Touch targets accesibles (WCAG 2.5.5) para
.btn-group>.btn-smya están resueltos globalmente enpublic/css/core/common.css(@media (pointer: coarse)), no crear una regla duplicada por módulo: al auditar un módulo con la skillimpeccabley encontrar botones de acción agrupados en tabla por debajo de 44x44px, verificar primero sicommon.cssya cubre el caso antes de agregar CSS al módulo — evita duplicación y reglas que se pisan entre sí. Ojo con@media (pointer: coarse)(dispositivo con puntero táctil) vs@media (max-width: ...)(ancho de viewport): son condiciones distintas — Playwright conpage.setViewportSize()NO activapointer: coarse(sigue siendo puntero "fino"), por lo que probar solo con resize de viewport no revela bugs de esta regla; hay que emular puntero táctil (DevTools "device toolbar" o contexto Playwright conhasTouch/isMobile) para verla en acción. Bug real encontrado y corregido 2026-08-11: la regla usabamargin-left: 2pxentre botones adyacentes del grupo, rompiendo visualmente el empalme sin costuras que Bootstrap/AdminLTE ya logra conmargin-left: -1px(ver.btn-group>.btn:not(:first-child)enadminlte.min.css) — corregido a-1pxencommon.css. - Confirmación de cambio de estado (activar/desactivar) estandarizada en
confirmarCambioEstado()(public/js/core/common-utils.js): reemplaza el bloque repetido deSwal.fire({...}).then(...) => submitCsrfForm(...)que existía por separado en cada módulo (show-producto.js,index-productos.js,index-clientes.js). Al agregar un botón nuevo de activar/desactivar en cualquier módulo, usar este helper en vez de copiar el bloque de SweetAlert2 — recibe{ id, estadoActual, titulo, texto, actionUrl }y sigue la misma convención deUsuarioController::cambiarEstadoUsuario(): el endpoint destino espera el estado actual, no el deseado (hace el toggle internamente). - Card "Vista Previa" del formulario (create/update) es un patrón reutilizable, no exclusivo de productos: el markup vive en un partial (
views/productos/partials/vista_previa.php) y la lógica JS en un archivo compartido (public/js/modules/productos/vista-previa-producto.js) porquecreate.php/update.phpusan los mismos IDs de campo — evita duplicar HTML/JS entre ambos formularios. Clientes usa el mismo formato de cards por sección pero sin esta card (decisión explícita: no hay un preview visual equivalente al de producto). Si un módulo nuevo necesita una Vista Previa similar, seguir este mismo patrón de partial + JS compartido entre create/update en vez de duplicar entre ambas vistas. initializeTooltips()(public/js/core/common-utils.js) conplacement: 'auto'no es confiable en tablas de acciones (btn-groupen la última columna de un#tabla*): Popper compara espacio disponible en las 4 direcciones y, en filas cercanas al borde superior/derecho de la tarjeta, puede elegirleften vez detopde forma inconsistente entre filas — reproducido enviews/usuarios/index.phpincluso sin.table-responsivede por medio (descarta que sea un problema descrollParent). Fix aplicado 2026-08-11:initializeTooltips()ahora usaplacement: 'top'fijo (igual que el patrón ya usado enventas/index-ventas.jsycompras/index-compras.js, que llaman.tooltip()sin opciones y por eso nunca sufrieron este bug). Si se reutilizainitializeTooltips()en una tabla nueva con DataTables, agregar también undrawCallback: function() { initializeTooltips(); }en las opciones delDataTable()— sin esto, los tooltips solo funcionan en la página inicial del paginador, porque DataTables re-renderiza las filas en cadadrawy losdata-toggle="tooltip"nuevos quedan sin inicializar (verusuarios/index-usuarios.js). Cuidado con dónde se agrega:drawCallbackdebe ir como propiedad hermana a nivel raíz del objeto de opciones deDataTable({...}), no anidada dentro de"language": {...}— DataTables ignora silenciosamente claves desconocidas dentro delanguage, así que quedaría definida pero nunca se ejecutaría (bug real cometido y corregido enventas/index-ventas.jsel 2026-08-11: eldrawCallbackquedó pegado justo antes del}de cierre delanguageen vez de después). Al verificar en navegador tras tocar estos archivos.jsversionados por?v=<?= $appVersion ?>, forzar bypass de caché (hard reload / DevTools "Disable cache") — el query string no cambia entre ediciones dentro de la misma versión, así que el navegador puede seguir sirviendo la copia vieja en caché y dar un falso negativo.exportOptions.columnsde los botones de reporte de DataTables (copy/pdf/excel/csv/print) nunca debe incluir la columna "Acciones": esa columna solo contiene botones (Editar, Activar/Desactivar, etc.), sin texto exportable — incluirla deja una columna vacía en el PDF/Excel/CSV/impresión. Regla:columnsdebe listar únicamente los índices de columnas con datos reales (ej.[0, 1, 2, 3]si la tabla tiene 5 columnas y la última es Acciones), nunca el rango completo por copy-paste de otro módulo con distinto número de columnas (bug encontrado encategorias/index-categorias.js2026-08-26, copiado de un módulo con 6 columnas cuando la tabla real tenía 5).- Una tabla que usa DataTables no necesita el wrapper
<div class="table-responsive">: DataTables con"responsive": trueya maneja el overflow/colapso en viewports angostos por su cuenta; envolverla en.table-responsivees redundante (duplica el manejo de scroll horizontal) y no aporta nada. Usar.table-responsivesolo en tablas estáticas sin DataTables. - El control de acceso real de FlowPOS es
tienePermisoNombre() && esAdministrador()en cada vista, norequireRole():requireRole()(views/layouts/session.php) llevaba tiempo sin ningún call site real — quedó como código muerto desde que el proyecto migró a permisos granulares, y se eliminó durante la migración a RBAC por rol. Antes de reutilizar o "arreglar" una función de auth que parece obsoleta,grepsus call sites reales — puede llevar tiempo sin usarse sin que nadie lo haya notado. - Restricciones a nivel de acción (no de módulo) se resuelven con un rol-check puntual en el controlador, no ampliando el catálogo
permiso: el catálogo es de granularidad por módulo a propósito (ver nota siguiente). Cuando una sola acción dentro de un módulo debe restringirse a ciertos roles (ej. anular ventas: supervisor/admin sí, vendedor no —controllers/ventas/anular_venta.php, 1.2.1), gatear constrtolower($_SESSION['usuario_rol']) === '<rol>'en el controlador como barrera real (el POST directo evade el ocultar del botón en la vista) y ocultar el botón en la(s) vista(s) solo como limpieza de UI. Nombres de rol en minúsculas:administrador,supervisor,vendedor(database/migrations/2026_08_rbac.sql). Migrar a permisos de acción tipoventas.anularsolo si aparecen 3-4 reglas de este tipo; por un caso aislado no amortiza. - El catálogo
permisoes un contrato con el código, no dato editable por UI: cadanombreestá hardcodeado en ~40 llamadastienePermisoNombre($id, 'ventas')repartidas por el proyecto. Cualquier módulo que ofrezca CRUD sobre esta tabla (crear/renombrar/eliminar permisos desde la UI) introduce un kill-switch de acceso global si alguien edita o borra un nombre que el código espera literal.views/permisos/index.phpes intencionalmente de solo lectura por esta razón — la gestión editable vive enrolpermiso(asignación por rol), no en el catálogo mismo. - Antes de ejecutar un paso destructivo de una migración incremental (DROP COLUMN, DROP TABLE), re-
grepel código en busca de lecturas que sigan dependiendo de lo que se va a eliminar — no confiar en que un corte de lectura previo "ya quedó completo" solo porque así lo dice la documentación/memoria de una sesión anterior: en la migración a RBAC por rol de FlowPOS, el corte de lectura de dashboards/sesión (cargo→idrol/rol) se había dado por cerrado, peroDashboardSupervisorController(5 queries SQL crudas conLOWER(u.cargo)='vendedor'), los guards deget_vendedor_dashboard_data.php/get_supervisor_dashboard_data.php,session.php::getCurrentUser(),AuthControllerymodels/Sesion.phpseguían leyendo la columna en vivo. Ejecutar elDROP COLUMN cargosin detectar esto habría roto el acceso a los dashboards de supervisor/vendedor en producción. Regla general: antes de unDROP/ALTERirreversible sobre una columna/tabla que el código leía, corrergrep -rn "<columna>"sobre todo el árbol (no solo los archivos que la sesión actual recuerda haber tocado) y confirmar que cada resultado restante es intencional (mirror-write a retirar en el mismo paso, o alias de presentación ya migrado a otra fuente). Aun así, un lector huérfano se coló tras elDROP TABLE permisousuario:views/usuarios/show.phpseguía llamandoAuthorizationService::obtenerPermisosAsignados()(que leía esa tabla) porque elgrepde cierre buscó la columna/tabla en el código de escritura y lectura de autorización, pero no se re-verificó cada vista de presentación que consumía esos métodos indirectamente — la llamada fallaba silenciosamente (catch +error_log) devolviendo[], sin error visible, mostrando un mensaje incorrecto ("sin permisos asignados") en vez de romper obviamente. Corolario: tras unDROP, además delgrepde la columna/tabla, revisar también los métodos de servicio que quedaron@deprecated/rotos por el DROP ygrepsus call sites — un método que sigue siendo código válido pero apunta a una tabla inexistente falla en runtime sin quephp -llo detecte.