Appearance
Arquitectura Backend Legacy
Estado: ⚠️ LEGACY - Solo mantenimiento
ARQUITECTURA DEPRECADA
Esta arquitectura está obsoleta y no debe usarse para nuevos desarrollos. Solo se mantiene para dar soporte al código existente. Para nuevos endpoints, consulte Arquitectura Moderna Slim Framework.
1. Visión General
Contexto Histórico
El backend legacy de Sistema Bautista se construyó con endpoints basados en archivos PHP individuales antes de la adopción de Slim Framework. Esta arquitectura se mantiene activa para:
- ✅ Mantenimiento de endpoints existentes
- ✅ Corrección de bugs en funcionalidades legacy
- ❌ NO para nuevos endpoints o refactorizaciones mayores
Características Principales
- Routing file-based: URL mapped directamente a archivos PHP
- Switch HTTP manual: Cada archivo maneja métodos HTTP con
switch - Sin middleware global: Validación y autenticación por archivo
- Controllers legacy: Lógica de negocio + orquestación (responsabilidad excesiva)
- Models legacy: Validaciones + acceso a DB (acoplamiento alto)
- Query directa: Algunos endpoints sin controllers/models
Stack Tecnológico
| Componente | Tecnología | Versión |
|---|---|---|
| Runtime | PHP | 8.2+ |
| Database | PostgreSQL | 12+ |
| PDO | Native | - |
| Auth | JWT (RSA) | Custom |
| Validation | Rakit Validation | 1.x |
2. Estructura de Archivos
Paths Completos desde Root
Todos los paths se especifican desde la raíz del repositorio bautista-backend/:
bautista-backend/
├── backend/ # ⚠️ ENDPOINTS LEGACY (DEPRECADO)
│ ├── carteras.php # Endpoint de carteras (con controller)
│ ├── dolar.php # Cotización dólar (query directa)
│ ├── cliente.php # Clientes (query directa)
│ ├── proveedor.php # Proveedores (query directa)
│ │
│ ├── mod-compras/ # Módulo de compras
│ │ ├── comprobante.php # Comprobantes de compra
│ │ ├── concepto.php # Conceptos de compra
│ │ ├── tipo-comprobante.php # Tipos de comprobante
│ │ └── libro-digital.php # Libro digital de compras
│ │
│ ├── mod-contabilidad/ # Módulo de contabilidad
│ │ └── ...
│ │
│ ├── mod-ventas/ # Módulo de ventas
│ │ └── ...
│ │
│ ├── mod-ctacte/ # Cuenta corriente
│ │ └── ...
│ │
│ ├── mod-tesoreria/ # Tesorería
│ │ └── ...
│ │
│ ├── mod-stock/ # Stock
│ │ └── ...
│ │
│ ├── mod-crm/ # CRM
│ │ └── ...
│ │
│ └── legacy/ # Código legacy muy antiguo
│ └── ...
│
├── controller/ # Controllers legacy (parcialmente)
│ ├── general/
│ │ └── CarteraController.php # Controller de carteras
│ ├── Compra/
│ │ ├── ComprobanteController.php
│ │ └── ConceptoController.php
│ └── ...
│
├── models/ # Models legacy (parcialmente)
│ ├── Cartera.php
│ ├── Cliente.php
│ └── ...
│
├── auth/ # Autenticación compartida
│ ├── JwtHandler.php # Manejo JWT (legacy + moderno)
│ └── ...
│
├── helper/ # Helpers compartidos
│ ├── exceptions.php # Excepciones custom
│ ├── exceptionHandler.php # Manejo global de excepciones
│ ├── success.php # Respuestas de éxito
│ ├── methods.php # Métodos de utilidad
│ └── validator.php # Validador Rakit
│
├── connection/ # Conexión base de datos
│ ├── connect.php # Inicialización de conexión
│ ├── Database.php # Clase Database (PDO wrapper)
│ └── ConnectionManager.php # Multi-tenant manager (legacy + moderno)
│
└── Routes/ # ✅ ENDPOINTS MODERNOS (Slim Framework)
├── Compras/
│ └── ComprobanteRoutes.php
└── ...Convención de Naming
| Tipo | Pattern | Ejemplo | Path |
|---|---|---|---|
| Endpoint genérico | {entidad}.php | carteras.php | backend/ |
| Endpoint de módulo | {entidad}.php | comprobante.php | backend/mod-{modulo}/ |
| Controller legacy | {Entidad}Controller.php | CarteraController.php | controller/general/ |
| Model legacy | {Entidad}.php | Cartera.php | models/ |
3. Patrones de Implementación
3.1. Endpoint con Controller/Model
Patrón: Endpoint delega a controller, que usa model para DB.
Ubicación:
- Endpoint:
bautista-backend/backend/{entidad}.php - Controller:
bautista-backend/controller/general/{Entidad}Controller.php - Model:
bautista-backend/models/{Entidad}.php
Ejemplo completo:
Endpoint: bautista-backend/backend/carteras.php
php
<?php
use App\controller\general\CarteraController;
require_once('../auth/JwtHandler.php');
require_once('../helper/exceptions.php');
require_once('../helper/exceptionHandler.php');
require_once('../helper/success.php');
require_once('../helper/methods.php');
require_once('../helper/validator.php');
require_once('../connection/connect.php');
header('content-type: application/json; charset=utf-8');
try {
// JWT ya validado en JwtHandler.php
$payload = $GLOBALS['payload'];
['db' => $db, 'schema' => $schema] = $payload;
// Parsear body JSON
$array = json_decode(file_get_contents('php://input'), true);
// Conexión a base de datos
$database = new Database($db, $schema);
$conn = $database->getConnection();
// Inicializar controller
$carteraController = new CarteraController($conn);
// Switch manual de HTTP methods
switch ($_SERVER['REQUEST_METHOD']) {
case 'GET':
$result = $carteraController->getAll($array ?? $_GET);
http_response_code(200);
echo getSuccessResponse(200, $result);
break;
case 'POST':
$result = $carteraController->insert($array);
if ($result) {
http_response_code(201);
echo getSuccessResponse(201, ['id' => $result]);
} else {
throw new InsertError("Error al insertar una nueva cartera");
}
break;
case 'PUT':
$result = $carteraController->update($array);
if ($result) {
http_response_code(204);
echo getSuccessResponse(204);
} else {
throw new UpdateError("Error al modificar la cartera");
}
break;
case 'DELETE':
$result = $carteraController->delete($array);
if ($result) {
http_response_code(204);
echo getSuccessResponse(204);
} else {
throw new DeleteError("Error al eliminar la cartera");
}
break;
default:
http_response_code(404);
echo json_encode(['error' => "Recurso no encontrado"]);
break;
}
} catch (Exception $e) {
ExceptionHandler::handle($e);
}Características del patrón:
- ✅ JWT validado en
JwtHandler.php(require) - ✅ Payload extraído de
$GLOBALS['payload'] - ✅ Switch manual para HTTP methods
- ✅ Controller inicializado con conexión PDO
- ✅ Response con
getSuccessResponse() - ✅ Manejo de excepciones con
ExceptionHandler
Controller Legacy: bautista-backend/controller/general/CarteraController.php
php
<?php
namespace App\controller\general;
use App\models\Cartera;
use PDO;
class CarteraController
{
private PDO $conn;
public function __construct(PDO $conn)
{
$this->conn = $conn;
}
/**
* Obtener todas las carteras
*
* @param array $params Parámetros de filtrado
* @return array
*/
public function getAll(array $params = []): array
{
$model = new Cartera($this->conn);
// Filtros opcionales
if (isset($params['tipo'])) {
$model->setTipo($params['tipo']);
}
if (isset($params['filter'])) {
$model->setFilter($params['filter']);
}
return $model->getAll();
}
/**
* Insertar nueva cartera
*
* @param array $data Datos de cartera
* @return int|false ID de cartera insertada o false
*/
public function insert(array $data): int|false
{
$model = new Cartera($this->conn);
// Validar datos
if (!$this->validate($data, 'insert')) {
throw new \InvalidArgumentException("Datos de cartera inválidos");
}
// Asignar datos al model
$model->setNombre($data['nombre']);
$model->setTipo($data['tipo']);
if (isset($data['descripcion'])) {
$model->setDescripcion($data['descripcion']);
}
// Insertar
return $model->insert();
}
/**
* Actualizar cartera existente
*
* @param array $data Datos de cartera
* @return bool
*/
public function update(array $data): bool
{
$model = new Cartera($this->conn);
// Validar datos
if (!$this->validate($data, 'update')) {
throw new \InvalidArgumentException("Datos de cartera inválidos");
}
// Asignar datos
$model->setId($data['id']);
$model->setNombre($data['nombre']);
$model->setTipo($data['tipo']);
if (isset($data['descripcion'])) {
$model->setDescripcion($data['descripcion']);
}
// Actualizar
return $model->update();
}
/**
* Eliminar cartera
*
* @param array $data Datos con ID de cartera
* @return bool
*/
public function delete(array $data): bool
{
if (!isset($data['id'])) {
throw new \InvalidArgumentException("ID de cartera requerido");
}
$model = new Cartera($this->conn);
$model->setId($data['id']);
return $model->delete();
}
/**
* Validar datos de cartera
*
* @param array $data Datos a validar
* @param string $operation Operación (insert|update)
* @return bool
*/
private function validate(array $data, string $operation): bool
{
// Validación básica
if ($operation === 'insert') {
if (empty($data['nombre']) || empty($data['tipo'])) {
return false;
}
}
if ($operation === 'update') {
if (empty($data['id']) || empty($data['nombre']) || empty($data['tipo'])) {
return false;
}
}
return true;
}
}Problemas del Controller Legacy:
- ⚠️ Demasiada responsabilidad (validación + orquestación + lógica de negocio)
- ⚠️ Validación manual (no usa Validator middleware)
- ⚠️ Sin separación de service layer
- ⚠️ Lógica acoplada al model
Model Legacy: bautista-backend/models/Cartera.php
php
<?php
namespace App\models;
use PDO;
class Cartera
{
private PDO $conn;
private ?int $id = null;
private ?string $nombre = null;
private ?string $tipo = null;
private ?string $descripcion = null;
private ?string $filter = null;
public function __construct(PDO $conn)
{
$this->conn = $conn;
}
// Getters y Setters
public function setId(int $id): void
{
$this->id = $id;
}
public function setNombre(string $nombre): void
{
$this->nombre = $nombre;
}
public function setTipo(string $tipo): void
{
$this->tipo = $tipo;
}
public function setDescripcion(?string $descripcion): void
{
$this->descripcion = $descripcion;
}
public function setFilter(string $filter): void
{
$this->filter = $filter;
}
/**
* Obtener todas las carteras
*
* @return array
*/
public function getAll(): array
{
$sql = "SELECT * FROM carteras WHERE 1=1";
$params = [];
// Filtro por tipo
if ($this->tipo !== null) {
$sql .= " AND tipo = :tipo";
$params[':tipo'] = $this->tipo;
}
// Filtro de búsqueda
if ($this->filter !== null) {
$sql .= " AND (nombre ILIKE :filter OR descripcion ILIKE :filter)";
$params[':filter'] = "%{$this->filter}%";
}
$sql .= " ORDER BY nombre ASC";
$stmt = $this->conn->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
/**
* Insertar nueva cartera
*
* @return int|false ID de cartera insertada
*/
public function insert(): int|false
{
$sql = "INSERT INTO carteras (nombre, tipo, descripcion)
VALUES (:nombre, :tipo, :descripcion)
RETURNING id";
$stmt = $this->conn->prepare($sql);
$stmt->bindParam(':nombre', $this->nombre);
$stmt->bindParam(':tipo', $this->tipo);
$stmt->bindParam(':descripcion', $this->descripcion);
if ($stmt->execute()) {
return (int)$stmt->fetchColumn();
}
return false;
}
/**
* Actualizar cartera existente
*
* @return bool
*/
public function update(): bool
{
$sql = "UPDATE carteras
SET nombre = :nombre,
tipo = :tipo,
descripcion = :descripcion
WHERE id = :id";
$stmt = $this->conn->prepare($sql);
$stmt->bindParam(':id', $this->id);
$stmt->bindParam(':nombre', $this->nombre);
$stmt->bindParam(':tipo', $this->tipo);
$stmt->bindParam(':descripcion', $this->descripcion);
return $stmt->execute();
}
/**
* Eliminar cartera (soft delete)
*
* @return bool
*/
public function delete(): bool
{
$sql = "UPDATE carteras SET eliminado = 1 WHERE id = :id";
$stmt = $this->conn->prepare($sql);
$stmt->bindParam(':id', $this->id);
return $stmt->execute();
}
}Problemas del Model Legacy:
- ⚠️ Mezcla validación con acceso a DB
- ⚠️ Sin DTOs (arrays asociativos directos)
- ⚠️ Lógica de filtrado en model (debería estar en service)
- ⚠️ Sin separación de responsabilidades
3.2. Endpoint con Query Directa (Sin Controller/Model)
Patrón: Endpoint ejecuta queries SQL directamente sin capas intermedias.
Ubicación: bautista-backend/backend/{entidad}.php
Ejemplo completo:
Endpoint: bautista-backend/backend/dolar.php
php
<?php
require_once('../auth/JwtHandler.php');
require_once('../helper/exceptions.php');
require_once('../helper/exceptionHandler.php');
require_once('../helper/success.php');
require_once('../helper/methods.php');
require_once('../helper/validator.php');
require_once('../connection/connect.php');
header('content-type: application/json; charset=utf-8');
try {
$payload = $GLOBALS['payload'];
['db' => $db, 'schema' => $schema] = $payload;
switch ($_SERVER['REQUEST_METHOD']) {
case 'GET':
// Conexión directa
$database = new Database($db, $schema);
$conn = $database->getConnection();
// Query directa
$sql = "SELECT * FROM dolar ORDER BY fecha DESC LIMIT 1";
$stmt = $conn->prepare($sql);
$stmt->execute();
if ($stmt->rowCount() === 1) {
$result = $stmt->fetch(PDO::FETCH_ASSOC);
$result['valor'] = (float)$result['valor'];
}
http_response_code(200);
echo getSuccessResponse(200, $result ?? []);
break;
case 'POST':
$array = json_decode(file_get_contents('php://input'), true);
$database = new Database($db, $schema);
$conn = $database->getConnection();
$values = [];
// Verificar si existe cotización del día
$sql = "SELECT * FROM dolar WHERE fecha = :fecha";
$stmt = $conn->prepare($sql);
$stmt->bindParam(':fecha', $array['fecha']);
$stmt->execute();
if ($stmt->rowCount() > 0) {
// Actualizar cotización existente
$sql = "UPDATE dolar SET valor = :valor WHERE fecha = :fecha";
} else {
// Insertar nueva cotización
$sql = "INSERT INTO dolar (fecha, valor) VALUES (:fecha, :valor)";
}
$values[':fecha'] = $array['fecha'];
$values[':valor'] = $array['valor'];
$stmt = $conn->prepare($sql);
$result = $stmt->execute($values);
if ($result) {
http_response_code(204);
echo getSuccessResponse(204);
} else {
throw new UpdateError("Error al actualizar la cotización del dólar");
}
break;
default:
http_response_code(404);
echo json_encode(['error' => "Recurso no encontrado"]);
break;
}
} catch (Exception $e) {
ExceptionHandler::handle($e);
}Características del patrón:
- ✅ Query SQL directa en endpoint
- ✅ Sin controller/model intermedios
- ✅ Lógica de negocio en endpoint
- ⚠️ No reutilizable
- ⚠️ Difícil de testear
- ⚠️ Violación de SRP (Single Responsibility Principle)
3.3. Endpoint con ConnectionManager (Multi-tenant)
Patrón: Endpoint usa ConnectionManager para manejar múltiples conexiones (oficial/prueba).
Ubicación: bautista-backend/backend/mod-{modulo}/{entidad}.php
Ejemplo completo:
Endpoint: bautista-backend/backend/mod-compras/comprobante.php
php
<?php
use App\connection\ConnectionManager;
use App\controller\Compra\ComprobanteController;
use App\Factories\ModelFactory;
use App\service\auditLog\AuditLogger;
require_once('../../auth/JwtHandler.php');
require_once('../../helper/exceptions.php');
require_once('../../helper/exceptionHandler.php');
require_once('../../helper/success.php');
require_once('../../helper/methods.php');
require_once('../../helper/validator.php');
require_once('../../connection/connect.php');
header('content-type: application/json; charset=utf-8');
try {
$payload = $GLOBALS['payload'];
['db' => $db, 'schema' => $schema] = $payload;
$array = json_decode(file_get_contents('php://input'), true);
// Inicializar ConnectionManager
$connectionManager = new ConnectionManager();
// Configurar conexiones (multi-database para oficial/prueba)
$connectionManager->setConfig('oficial', [
'database' => $db,
'schema' => $schema
]);
$connectionManager->setConfig('prueba', [
'database' => $db . '_p', // Base de datos de prueba con sufijo _p
'schema' => $schema
]);
// Conexión principal según parámetro 'prueba'
$connectionManager->setConfig('principal', [
'database' => isset($array['prueba']) && $array['prueba'] ? $db . '_p' : $db,
'schema' => $schema
]);
// Alias para compatibilidad
$connectionManager->setAlias('oficial_dbal', 'oficial');
$connectionManager->setAlias('prueba_dbal', 'prueba');
// Inicializar dependencias
$modelFactory = $container->get(ModelFactory::class);
$auditLogger = new AuditLogger($modelFactory);
// Controller con ConnectionManager
$comprobanteController = new ComprobanteController($connectionManager, $auditLogger);
switch ($_SERVER['REQUEST_METHOD']) {
case 'GET':
$response = $comprobanteController->getOne($array);
http_response_code(200);
echo getSuccessResponse(200, $response);
break;
case 'POST':
$response = $comprobanteController->insert($array);
http_response_code(201);
echo getSuccessResponse(201, $response);
break;
case 'PUT':
$response = $comprobanteController->update($array);
http_response_code(200);
echo getSuccessResponse(200, $response);
break;
case 'DELETE':
$response = $comprobanteController->delete($array);
http_response_code(200);
echo getSuccessResponse(200, $response);
break;
default:
http_response_code(404);
echo json_encode(['error' => "Recurso no encontrado"]);
break;
}
} catch (Exception $e) {
ExceptionHandler::handle($e);
}Características del patrón:
- ✅ ConnectionManager para multi-tenant
- ✅ Conexiones:
oficial,prueba,principal - ✅ Base de datos de prueba con sufijo
_p - ✅ Parámetro
pruebadetermina DB activa - ✅ Alias para compatibilidad con código moderno
3.4. Autenticación JWT
Archivo: bautista-backend/auth/JwtHandler.php
Todos los endpoints legacy requieren JwtHandler.php para validar JWT.
php
<?php
require_once('../auth/JwtHandler.php');
// JWT ya validado automáticamente
$payload = $GLOBALS['payload'];
['db' => $db, 'schema' => $schema] = $payload;Payload JWT:
php
[
'id' => 123, // ID de usuario (desde v3.9.1)
'user' => 'admin', // Username
'db' => 'suc0001', // Nombre de base de datos
'schema' => 'suc0001', // Schema multi-tenant
'exp' => 1738584000, // Timestamp de expiración
'permissions' => [...] // Permisos del usuario
]3.5. Helpers Compartidos
Helper de Éxito: helper/success.php
php
<?php
/**
* Generar respuesta de éxito estándar
*
* @param int $code Código HTTP
* @param mixed $data Datos a retornar
* @return string JSON
*/
function getSuccessResponse(int $code, $data = null): string
{
$response = [
'success' => true,
'code' => $code
];
if ($data !== null) {
$response['data'] = $data;
}
return json_encode($response);
}Uso:
php
http_response_code(200);
echo getSuccessResponse(200, ['id' => 123, 'nombre' => 'Cartera 1']);Helper de Excepciones: helper/exceptionHandler.php
php
<?php
class ExceptionHandler
{
public static function handle(Exception $e): void
{
$code = $e->getCode() ?: 500;
http_response_code($code);
echo json_encode([
'error' => true,
'code' => $code,
'message' => $e->getMessage(),
'trace' => DEBUG ? $e->getTraceAsString() : null
]);
}
}Excepciones Custom (helper/exceptions.php):
php
<?php
class InsertError extends Exception
{
public function __construct(string $message = "Error al insertar registro")
{
parent::__construct($message, 500);
}
}
class UpdateError extends Exception
{
public function __construct(string $message = "Error al actualizar registro")
{
parent::__construct($message, 500);
}
}
class DeleteError extends Exception
{
public function __construct(string $message = "Error al eliminar registro")
{
parent::__construct($message, 500);
}
}
class MissingSession extends Exception
{
public function __construct(string $message = "Sesión no válida")
{
parent::__construct($message, 401);
}
}4. Problemas de Arquitectura
4.1. Routing Basado en Archivos
Problema: URL mappea directamente a archivos PHP físicos.
❌ URL física determina endpoint
POST /backend/mod-compras/comprobante.php
↓ Mapea a archivo
bautista-backend/backend/mod-compras/comprobante.phpConsecuencias:
- ⚠️ Estructura de directorios expuesta
- ⚠️ Refactoring difícil (cambiar path = cambiar URL)
- ⚠️ Sin versioning de API
- ⚠️ Sin parámetros de ruta (
/comprobantes/:id)
Solución: Slim Framework con routing declarativo.
php
// ✅ Arquitectura moderna
$app->get('/comprobantes/{id}', [ComprobanteController::class, 'getOne']);4.2. Switch Manual HTTP
Problema: Cada archivo maneja métodos HTTP con switch manual.
php
// ❌ Switch manual por archivo
switch ($_SERVER['REQUEST_METHOD']) {
case 'GET':
// Lógica GET
break;
case 'POST':
// Lógica POST
break;
case 'PUT':
// Lógica PUT
break;
case 'DELETE':
// Lógica DELETE
break;
}Consecuencias:
- ⚠️ Código repetitivo en cada endpoint
- ⚠️ Sin middleware HTTP global
- ⚠️ Difícil agregar logging/metricas
- ⚠️ Validación manual por método
Solución: Slim Framework con routing por método.
php
// ✅ Routing declarativo
$app->get('/comprobantes', [ComprobanteController::class, 'getAll']);
$app->post('/comprobantes', [ComprobanteController::class, 'create']);
$app->put('/comprobantes/{id}', [ComprobanteController::class, 'update']);
$app->delete('/comprobantes/{id}', [ComprobanteController::class, 'delete']);4.3. Lógica Dispersa
Problema: Controllers legacy mezclan validación + orquestación + lógica de negocio.
php
// ❌ Controller con demasiada responsabilidad
class CarteraController
{
public function insert(array $data): int|false
{
// Validación
if (empty($data['nombre'])) {
throw new \InvalidArgumentException("Nombre requerido");
}
// Orquestación
$model = new Cartera($this->conn);
$model->setNombre($data['nombre']);
// Lógica de negocio
if ($this->existeCartera($data['nombre'])) {
throw new \Exception("Cartera duplicada");
}
// Acceso a DB (delegado a model)
return $model->insert();
}
}Consecuencias:
- ⚠️ Difícil de testear (múltiples responsabilidades)
- ⚠️ Código duplicado entre controllers
- ⚠️ Lógica de negocio acoplada a infraestructura
Solución: Arquitectura 5 capas moderna.
php
// ✅ Separación de responsabilidades
Route → Validation Middleware → Controller → Service → Model4.4. Sin DTOs
Problema: Arrays asociativos en lugar de objetos tipados.
php
// ❌ Arrays sin tipo
public function insert(array $data): int|false
{
$nombre = $data['nombre']; // No type-safe
$tipo = $data['tipo']; // Posibles errores en runtime
}Consecuencias:
- ⚠️ Sin type hints (errores en runtime)
- ⚠️ Sin validación estática (IDE no detecta errores)
- ⚠️ Difícil refactorizar
- ⚠️ Documentación implícita (no explícita)
Solución: DTOs con tipos estrictos.
php
// ✅ DTOs tipados
class CarteraInputDTO extends FullDTO
{
public function __construct(
public readonly string $nombre,
public readonly string $tipo,
public readonly ?string $descripcion = null
) {}
}
public function insert(CarteraInputDTO $dto): int
{
// Type-safe
}4.5. Sin Testing
Problema: Endpoints legacy no diseñados para testing.
Consecuencias:
- ⚠️ Sin tests unitarios (lógica mezclada)
- ⚠️ Sin tests de integración (dependencia de archivos físicos)
- ⚠️ Refactoring riesgoso
- ⚠️ Regresiones frecuentes
Solución: Arquitectura testeable con DI.
php
// ✅ Testeable con DI
class ComprobanteService
{
public function __construct(
private ConnectionManager $connectionManager,
private AuditLogger $auditLogger
) {}
public function create(ComprobanteInputDTO $dto): ComprobanteOutputDTO
{
// Lógica testeable
}
}
// Test unitario
$service = new ComprobanteService(
$this->createMock(ConnectionManager::class),
$this->createMock(AuditLogger::class)
);5. Migración a Slim Framework (Paso a Paso)
Estrategia de Migración
ESTRATEGIA PROGRESIVA
La migración a Slim Framework se hace gradualmente refactorizando endpoints de alto impacto primero.
Paso 1: Identificar Endpoint a Migrar
Priorizar endpoints con:
- ✅ Alto uso (frecuencia de llamadas)
- ✅ Lógica compleja (difícil de mantener)
- ✅ Requisitos de testing
- ✅ Necesidad de refactoring
Ejemplo: Migrar backend/mod-compras/comprobante.php
Paso 2: Crear Route en Slim
Archivo: bautista-backend/Routes/Compras/ComprobanteRoutes.php
php
<?php
declare(strict_types=1);
namespace App\Routes\Compras;
use App\controller\Compra\ComprobanteController;
use App\middleware\ValidationMiddleware;
use Slim\Routing\RouteCollectorProxy;
return function (RouteCollectorProxy $group) {
$group->group('/comprobantes', function (RouteCollectorProxy $comprobantes) {
// GET /comprobantes?filter=...
$comprobantes->get('', [ComprobanteController::class, 'getAll']);
// GET /comprobantes/{id}
$comprobantes->get('/{id:[0-9]+}', [ComprobanteController::class, 'getOne']);
// POST /comprobantes
$comprobantes->post('', [ComprobanteController::class, 'create'])
->add(new ValidationMiddleware('comprobante', 'create'));
// PUT /comprobantes/{id}
$comprobantes->put('/{id:[0-9]+}', [ComprobanteController::class, 'update'])
->add(new ValidationMiddleware('comprobante', 'update'));
// DELETE /comprobantes/{id}
$comprobantes->delete('/{id:[0-9]+}', [ComprobanteController::class, 'delete']);
});
};Paso 3: Crear Controller Moderno
Archivo: bautista-backend/controller/Compra/ComprobanteController.php
php
<?php
declare(strict_types=1);
namespace App\controller\Compra;
use App\controller\Controller;
use App\service\Compra\ComprobanteService;
use App\Resources\Compra\ComprobanteInputDTO;
use App\Resources\Compra\ComprobanteOutputDTO;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
class ComprobanteController extends Controller
{
public function __construct(
private ComprobanteService $comprobanteService
) {}
/**
* Crear nuevo comprobante
*/
public function create(Request $request, Response $response): Response
{
// Parsear body validado
$data = $request->getParsedBody();
// Crear DTO de entrada
$dto = ComprobanteInputDTO::fromArray($data);
// Delegar a service
$result = $this->comprobanteService->create($dto);
// Responder con DTO de salida
return $this->respondWithData($response, $result->toArray(), 201);
}
/**
* Actualizar comprobante existente
*/
public function update(Request $request, Response $response, array $args): Response
{
$id = (int)$args['id'];
$data = $request->getParsedBody();
$dto = ComprobanteInputDTO::fromArray($data);
$result = $this->comprobanteService->update($id, $dto);
return $this->respondWithData($response, $result->toArray(), 200);
}
/**
* Obtener comprobante por ID
*/
public function getOne(Request $request, Response $response, array $args): Response
{
$id = (int)$args['id'];
$result = $this->comprobanteService->getById($id);
return $this->respondWithData($response, $result->toArray(), 200);
}
/**
* Eliminar comprobante
*/
public function delete(Request $request, Response $response, array $args): Response
{
$id = (int)$args['id'];
$this->comprobanteService->delete($id);
return $response->withStatus(204);
}
}Paso 4: Crear Service Layer
Archivo: bautista-backend/service/Compra/ComprobanteService.php
php
<?php
declare(strict_types=1);
namespace App\service\Compra;
use App\connection\ConnectionManager;
use App\models\Compra\Comprobante;
use App\Resources\Compra\ComprobanteInputDTO;
use App\Resources\Compra\ComprobanteOutputDTO;
use App\service\auditLog\AuditLogger;
class ComprobanteService
{
use \App\Traits\Conectable;
public function __construct(
private ConnectionManager $connectionManager,
private AuditLogger $auditLogger
) {}
/**
* Crear nuevo comprobante
*/
public function create(ComprobanteInputDTO $dto): ComprobanteOutputDTO
{
// Obtener conexión principal
$conn = $this->getConnection('principal');
// Iniciar transacción
$this->beginTransaction('principal');
try {
// Crear model
$model = new Comprobante($conn);
// Asignar datos del DTO
$model->setNumeroComprobante($dto->numeroComprobante);
$model->setFecha($dto->fecha);
$model->setProveedor($dto->proveedorId);
$model->setImporte($dto->importe);
// Insertar
$id = $model->insert();
// Audit log
$this->auditLogger->log('comprobante', $id, 'INSERT');
// Commit
$this->commit('principal');
// Obtener comprobante creado
return $this->getById($id);
} catch (\Exception $e) {
$this->rollback('principal');
throw $e;
}
}
/**
* Obtener comprobante por ID
*/
public function getById(int $id): ComprobanteOutputDTO
{
$conn = $this->getConnection('principal');
$model = new Comprobante($conn);
$data = $model->getById($id);
if (empty($data)) {
throw new \Exception("Comprobante no encontrado", 404);
}
return ComprobanteOutputDTO::fromArray($data);
}
/**
* Actualizar comprobante
*/
public function update(int $id, ComprobanteInputDTO $dto): ComprobanteOutputDTO
{
$conn = $this->getConnection('principal');
$this->beginTransaction('principal');
try {
$model = new Comprobante($conn);
$model->setId($id);
$model->setNumeroComprobante($dto->numeroComprobante);
$model->setFecha($dto->fecha);
$model->setProveedor($dto->proveedorId);
$model->setImporte($dto->importe);
$model->update();
$this->auditLogger->log('comprobante', $id, 'UPDATE');
$this->commit('principal');
return $this->getById($id);
} catch (\Exception $e) {
$this->rollback('principal');
throw $e;
}
}
/**
* Eliminar comprobante (soft delete)
*/
public function delete(int $id): void
{
$conn = $this->getConnection('principal');
$this->beginTransaction('principal');
try {
$model = new Comprobante($conn);
$model->setId($id);
$model->delete();
$this->auditLogger->log('comprobante', $id, 'DELETE');
$this->commit('principal');
} catch (\Exception $e) {
$this->rollback('principal');
throw $e;
}
}
}Paso 5: Crear DTOs
Input DTO: bautista-backend/Resources/Compra/ComprobanteInputDTO.php
php
<?php
declare(strict_types=1);
namespace App\Resources\Compra;
use App\Resources\FullDTO;
class ComprobanteInputDTO extends FullDTO
{
public function __construct(
public readonly string $numeroComprobante,
public readonly string $fecha,
public readonly int $proveedorId,
public readonly float $importe,
public readonly ?string $comentario = null
) {}
}Output DTO: bautista-backend/Resources/Compra/ComprobanteOutputDTO.php
php
<?php
declare(strict_types=1);
namespace App\Resources\Compra;
use App\Resources\FullDTO;
class ComprobanteOutputDTO extends FullDTO
{
public function __construct(
public readonly int $id,
public readonly string $numeroComprobante,
public readonly string $fecha,
public readonly int $proveedorId,
public readonly string $proveedorNombre,
public readonly float $importe,
public readonly ?string $comentario,
public readonly string $fechaCreacion
) {}
}Paso 6: Crear Validadores
Archivo: bautista-backend/validators/comprobante.php
php
<?php
return [
'create' => [
'numeroComprobante' => 'required|string|max:50',
'fecha' => 'required|date:Y-m-d',
'proveedorId' => 'required|integer|min:1',
'importe' => 'required|numeric|min:0',
'comentario' => 'nullable|string|max:500'
],
'update' => [
'numeroComprobante' => 'required|string|max:50',
'fecha' => 'required|date:Y-m-d',
'proveedorId' => 'required|integer|min:1',
'importe' => 'required|numeric|min:0',
'comentario' => 'nullable|string|max:500'
]
];Paso 7: Registrar Route en index.php
Archivo: bautista-backend/index.php
php
<?php
// Registrar routes de Compras
$app->group('/compras', function (RouteCollectorProxy $group) {
// Comprobantes
require __DIR__ . '/Routes/Compras/ComprobanteRoutes.php';
})->add($authMiddleware);Paso 8: Deprecar Endpoint Legacy
Opción A: Comentar código legacy
php
<?php
// ⚠️ DEPRECADO - Migrado a Routes/Compras/ComprobanteRoutes.php
// Este endpoint será eliminado en v4.0.0
// Por favor, usar: POST /compras/comprobantes
die(json_encode([
'error' => 'Endpoint deprecado',
'message' => 'Use POST /compras/comprobantes en su lugar'
]));Opción B: Proxy temporal
php
<?php
// ⚠️ PROXY TEMPORAL - Migrado a Slim Framework
// Redirigir al nuevo endpoint
header('Location: /compras/comprobantes', true, 301);
exit;Paso 9: Testing
Test Unitario: bautista-backend/Tests/Unit/Compras/ComprobanteServiceTest.php
php
<?php
namespace Tests\Unit\Compras;
use App\service\Compra\ComprobanteService;
use App\Resources\Compra\ComprobanteInputDTO;
use PHPUnit\Framework\TestCase;
class ComprobanteServiceTest extends TestCase
{
private ComprobanteService $service;
protected function setUp(): void
{
$connectionManager = $this->createMock(\App\connection\ConnectionManager::class);
$auditLogger = $this->createMock(\App\service\auditLog\AuditLogger::class);
$this->service = new ComprobanteService($connectionManager, $auditLogger);
}
public function testCreateComprobante(): void
{
$dto = new ComprobanteInputDTO(
numeroComprobante: 'FC-0001-00000001',
fecha: '2026-02-03',
proveedorId: 1,
importe: 10000.00
);
$result = $this->service->create($dto);
$this->assertInstanceOf(\App\Resources\Compra\ComprobanteOutputDTO::class, $result);
$this->assertEquals('FC-0001-00000001', $result->numeroComprobante);
}
}Test de Integración: bautista-backend/Tests/Integration/Compras/ComprobanteIntegrationTest.php
php
<?php
namespace Tests\Integration\Compras;
use Tests\Integration\BaseIntegrationTestCase;
class ComprobanteIntegrationTest extends BaseIntegrationTestCase
{
public function testCreateComprobanteEndpoint(): void
{
$data = [
'numeroComprobante' => 'FC-0001-00000001',
'fecha' => '2026-02-03',
'proveedorId' => 1,
'importe' => 10000.00
];
$response = $this->post('/compras/comprobantes', $data);
$this->assertEquals(201, $response->getStatusCode());
$body = json_decode((string)$response->getBody(), true);
$this->assertEquals('FC-0001-00000001', $body['data']['numeroComprobante']);
}
}Paso 10: Documentar Cambio
CHANGELOG.md:
markdown
## [v4.0.0] - 2026-02-15
### Features
- Endpoint `/compras/comprobantes` migrado a Slim Framework
### Deprecated
- `backend/mod-compras/comprobante.php` deprecado (eliminado en v5.0.0)
### Migration Guide
- Cambiar URL de `POST /backend/mod-compras/comprobante.php` a `POST /compras/comprobantes`
- Validación ahora en ValidationMiddleware (respuestas 422 en lugar de 400)
- DTOs tipados en lugar de arrays asociativos6. Helpers Compartidos
6.1. Autenticación
Archivo: bautista-backend/auth/JwtHandler.php
- ✅ Validación automática de JWT
- ✅ Payload extraído en
$GLOBALS['payload'] - ✅ Usado tanto en legacy como en Slim Framework
6.2. Conexión Base de Datos
Archivo: bautista-backend/connection/Database.php
php
<?php
class Database
{
private PDO $conn;
public function __construct(string $db, string $schema)
{
$dsn = "pgsql:host=" . DB_HOST . ";port=" . DB_PORT . ";dbname={$db}";
$this->conn = new PDO($dsn, DB_USER, DB_PASS);
$this->conn->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// Configurar schema multi-tenant
$this->conn->exec("SET search_path TO {$schema}, public");
}
public function getConnection(): PDO
{
return $this->conn;
}
}6.3. ConnectionManager
Archivo: bautista-backend/connection/ConnectionManager.php
- ✅ Manejo de múltiples conexiones (oficial/prueba/principal)
- ✅ Transacciones coordinadas
- ✅ Schema-based multi-tenancy
- ✅ Usado en legacy y arquitectura moderna
Uso en legacy:
php
$connectionManager = new ConnectionManager();
$connectionManager->setConfig('oficial', [
'database' => $db,
'schema' => $schema
]);
$connectionManager->setConfig('prueba', [
'database' => $db . '_p',
'schema' => $schema
]);
$conn = $connectionManager->getConnection('oficial');6.4. Comunicación Frontend → Backend
IMPORTANTE: API.js vs api.ts
Diferencia crítica en el frontend para comunicación con backend:
- API.js (Legacy): Solo para endpoints legacy (
backend/**/*.php) - api.ts con Axios (Moderno): Para endpoints Slim Framework (
Routes/**/*.php)
❌ API.js (Legacy) - Solo para Endpoints Legacy
Archivo: bautista-app/js/middleware/API.js
Uso EXCLUSIVO con endpoints legacy:
javascript
// ❌ Solo para endpoints legacy (backend/*.php)
import { ApiRequest } from '../../middleware/API.js';
const request = new ApiRequest();
// Legacy endpoint: backend/carteras.php
const response = await request.get('/backend/carteras.php', {
tipo: 'cliente'
});
// Legacy endpoint: backend/mod-compras/comprobante.php
const comprobante = await request.post('/backend/mod-compras/comprobante.php', {
numero: 'FC-0001-00000001',
importe: 10000
});Características:
- ⚠️ URL incluye path completo al archivo PHP
- ⚠️ Response format custom (no estándar)
- ⚠️ Sin type safety (JavaScript vanilla)
✅ api.ts con Axios (Moderno) - Para Endpoints Slim Framework
Archivo: bautista-app/ts/api/api.ts
Uso REQUERIDO con endpoints Slim modernos:
typescript
// ✅ Para endpoints Slim Framework modernos
import axios from '../api/api';
// Endpoint moderno: GET /compras/comprobantes
const response = await axios.get('/compras/comprobantes', {
params: {
filter: 'proveedor1'
}
});
// Endpoint moderno: POST /compras/comprobantes
const comprobante = await axios.post('/compras/comprobantes', {
numeroComprobante: 'FC-0001-00000001',
fecha: '2026-02-03',
proveedorId: 1,
importe: 10000.00
});
// Endpoint moderno: PUT /compras/comprobantes/123
const updated = await axios.put('/compras/comprobantes/123', {
importe: 12000.00
});
// Endpoint moderno: DELETE /compras/comprobantes/123
await axios.delete('/compras/comprobantes/123');Características:
- ✅ URL RESTful sin archivos físicos
- ✅ Response estándar (PSR-7)
- ✅ Type safety con TypeScript
- ✅ Axios interceptors (X-Schema header automático)
- ✅ Error handling consistente
Migración de API.js a api.ts
Antes (Legacy):
javascript
// ❌ API.js legacy
import { ApiRequest } from '../../middleware/API.js';
const request = new ApiRequest();
const response = await request.get('/backend/mod-compras/comprobante.php', {
id: 123
});Después (Moderno):
typescript
// ✅ api.ts con Axios
import axios from '../api/api';
const response = await axios.get('/compras/comprobantes/123');Tabla de Migración:
| Aspecto | API.js (Legacy) | api.ts (Moderno) |
|---|---|---|
| Endpoints | backend/**/*.php | Slim Routes (/compras/comprobantes) |
| Type Safety | ❌ No (JavaScript) | ✅ Sí (TypeScript) |
| URL Pattern | File-based (/backend/file.php) | RESTful (/resource/{id}) |
| Headers | Manual | Automático (interceptors) |
| Error Handling | Custom | Estándar (HTTP status) |
| Response Format | Custom | PSR-7 estándar |
REGLA DE ORO
Si el endpoint está en:
bautista-backend/backend/*.php→ Usar API.js (legacy)bautista-backend/Routes/**/*.php→ Usar api.ts (axios)
7. Troubleshooting
7.1. Error 401: Token inválido
Síntomas: {"error": "Token inválido"}
Causas:
- JWT expirado
- JWT malformado
- Private key incorrecta
- Header
Authorizationfaltante
Solución:
bash
# Verificar header en request
Authorization: Bearer <token>
# Verificar expiración en JWT debugger (jwt.io)
# Regenerar token si expiró7.2. Error 500: Schema no encontrado
Síntomas: ERROR: schema "suc0001" does not exist
Causas:
- Schema no creado en base de datos
- Payload JWT con schema incorrecto
- Migration no ejecutada
Solución:
bash
# Verificar schemas existentes
psql -d database -c "\dn"
# Ejecutar migrations
php migrations/migrate-db-command.php --migrate
# Verificar payload JWT
echo $GLOBALS['payload'];7.3. Error: Método HTTP no soportado
Síntomas: {"error": "Recurso no encontrado"}
Causas: Switch no maneja método HTTP enviado
Solución:
php
// Verificar que switch incluya el método
switch ($_SERVER['REQUEST_METHOD']) {
case 'GET':
// ...
break;
case 'POST':
// ...
break;
// ⚠️ Agregar PUT/DELETE si es necesario
default:
http_response_code(405); // Method Not Allowed
echo json_encode(['error' => "Método no soportado"]);
break;
}7.4. Error: Connection refused
Síntomas: Connection refused al conectar a PostgreSQL
Causas:
- PostgreSQL no iniciado
- Host/port incorrecto en
constants.php - Firewall bloqueando conexión
Solución:
bash
# Verificar PostgreSQL activo
sudo systemctl status postgresql
# Verificar configuración
grep -A 5 "DB_" constants.php
# Test de conexión
psql -h localhost -p 5432 -U postgres -d database7.5. Error: Multi-tenant no funciona
Síntomas: Datos de otra sucursal aparecen en respuesta
Causas:
search_pathno configurado correctamente- Schema incorrecto en payload JWT
- ConnectionManager mal configurado
Solución:
php
// Verificar search_path
$stmt = $conn->query("SHOW search_path");
$searchPath = $stmt->fetchColumn();
echo "Search path: " . $searchPath; // Debe ser: suc0001, public
// Verificar payload
var_dump($GLOBALS['payload']);
// ['db' => 'suc0001', 'schema' => 'suc0001']
// Verificar ConnectionManager
$conn = $connectionManager->getConnection('oficial');
$stmt = $conn->query("SELECT current_schema()");
echo "Current schema: " . $stmt->fetchColumn();8. Referencias
Documentación Relacionada
- Arquitectura Backend Moderna - Slim Framework 5 capas
- Multi-Tenant Architecture - ConnectionManager y schemas
- Migrations - Sistema de migraciones
- Frontend Legacy - PHP SSR y JS Vanilla
Ejemplos Reales en Código
| Módulo | Endpoint Legacy | Endpoint Moderno (Slim) |
|---|---|---|
| Carteras | backend/carteras.php | Routes/General/CarteraRoutes.php |
| Compras | backend/mod-compras/comprobante.php | Routes/Compras/ComprobanteRoutes.php |
| Dólar | backend/dolar.php | (Pendiente migración) |
Tecnologías
- PHP 8.2+: https://www.php.net/
- PostgreSQL: https://www.postgresql.org/
- PDO: https://www.php.net/manual/es/book.pdo.php
- Rakit Validation: https://github.com/rakit/validation
Migración a Slim
- Slim Framework: https://www.slimframework.com/
- PSR-7: https://www.php-fig.org/psr/psr-7/
- PHP-DI: https://php-di.org/
Resumen
RECORDATORIO FINAL
Esta arquitectura está DEPRECADA. Solo para mantenimiento de código existente.
Para nuevos desarrollos:
- ✅ Usar Arquitectura Slim Framework
- ✅ Seguir patrones de 5 capas (Route → Middleware → Controller → Service → Model)
- ✅ Implementar DTOs tipados
- ✅ Escribir tests (PHPUnit)
Puntos clave:
- ✅ Routing file-based (URL → archivo PHP)
- ✅ Switch HTTP manual en cada endpoint
- ✅ Controllers legacy con lógica mezclada
- ✅ Models legacy sin DTOs
- ✅ ConnectionManager para multi-tenant
- ✅ JWT compartido (JwtHandler.php)
- ✅ Migración gradual a Slim Framework siguiendo 10 pasos
- ✅ Testing crítico en migración