Desde hace algunos meses he estado intentando encontrar el punto perfecto para desarrollar con PHP sin usar ningún framework como Laravel o CodeIgniter.
Como desarrolladores, queremos algo que sea sólido, fácil de usar y confiable; además de que tenga rica documentación y su uso sea entendible.
Hoy vengo a presentar mi caja de herramientas que uso al desarrollar con PHP, la cual podría ser llamada framework.
También quiero mostrar cómo es que se pueden juntar las herramientas para tener una base sólida de desarrollo con PHP.
Nota: puedes ver la plantilla en GitHub.
Mi plantilla para desarrollar con PHP
He decidido usar los siguientes elementos.
Phroute para las rutas
Para tener un enrutador ligero y fácilmente configurable he usado phroute, pues permite los 4 verbos HTTP además de que es una librería ligera que soporta filtros (algo parecido a un middleware)
Twig para las vistas
Twig es un motor de plantillas seguro y con una larga presencia en el mundo del desarrollo web.
Es un simple motor como Blade, que permite hacer mejores plantillas de diseño. Nos olvidamos del include y de los bloques <?php echo $algo ?>
PHPMailer para los correos
Toda aplicación web en la nube que permita registro de usuarios necesita enviar correos de verificación; pues bien, PHPMailer viene como anillo al dedo cuando se trata de enviar correos electrónicos con facilidad.
Lo que me gusta es cómo se puede combinar con otros frameworks, por ejemplo, Twig, para obtener el HTML.
Valitron para la validación de formularios
Valitron es una librería de PHP que sirve para validar datos. Permite obtener los errores de validación como arreglo, es ligera, no tiene dependencias y simple.
PDO para la base de datos
Ya sé que PDO no es una librería de PHP, pero hace falta mencionar que uso este driver de PHP para interactuar con las bases de datos.
Bootstrap 4 para el diseño
No se está forzado a usar Bootstrap, pues las plantillas son totalmente modificables, sin embargo el código existente sugiere Bootstrap 4.
Un archivo env
Las credenciales y otros datos que no pueden ser puestos públicamente están dentro de un archivo env.php
; <?php exit; ?>
; El comentario de arriba es para que, si el archivo es visto
; desde el navegador, se salga inmediatamente del script, ocultando
; la información que aquí existe
; En cambio, cuando es tratado como un archivo .ini, las
; líneas que comienzan con un ; son ignoradas
; Un archivo de configuración
; que guarda todas las credenciales
; para cada cosa
; Las líneas en blanco y aquellas que comienzan
; con un punto y coma (;) son ignoradas
; URL base del proyecto, algo como https://sitio.com
URL_RAIZ = "http://localhost/notas_app"
USUARIO_MYSQL = "root"
PASS_MYSQL = ""
NOMBRE_BD_MYSQL = "notas_app"
HOST_MYSQL = "localhost"
;El offset para las rutas. Simplemente hay que contar el número
; de barras (/) desde la raíz
; Por ejemplo, si el index.php está en localhost/app/index.php
; el offset sería 2
; si estuviera en localhost/app/otro_dir/index.php
; el offset sería 3
; si estuviera en https://parzibyte.me/apps/app/index.php
; el offset sería 3
OFFSET_RUTAS = 2
HABILITAR_CACHE_TWIG = false
RUTA_CACHE_TWIG = "cache_twig"
DIRECCION_CORREO_REMITENTE = "tu_direccion@dominio.com"
NOMBRE_REMITENTE = "Nombre del remitente"
En el mismo también controlamos otros aspectos como el caché de Twig o las direcciones de correo del remitente.
Composer para las dependencias
Todas las dependencias son manejadas con Composer, y mi propio código también es cargado de esta manera.
Sesiones en la base de datos
Las sesiones son almacenadas en la base de datos, y gracias a que se cuenta con un manejador propio, se puede saber a qué usuario le pertenece cada sesión, permitiendo cerrarla remotamente.
No hay ORM
No he utilizado ningún ORM, todas las consultas son “a mano”. Obviamente esto podría cambiarse al instalar una librería para ello.
Un enfoque MVC
Para juntar todas estas tecnologías se utiliza composer, mi código también es cargado de esta forma.
Los controldores y modelos viven dentro de la carpeta app:
Ambos son automáticamente cargados por Composer.
El controlador se encarga de procesar y validar los datos con Valitron, para llamar a los modelos y finalmente redireccionar a las vistas con o sin datos flash de sesión.
Extender plantillas con Twig
Las vistas heredan de una plantilla maestra:
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta content="width=device-width, initial-scale=1.0" name="viewport">
<meta content="ie=edge" http-equiv="X-UA-Compatible">
<title>
{% block titulo %}{% endblock %}
-
{{NOMBRE_APLICACION}}</title>
<link rel="stylesheet" href="{{ URL_DIRECTORIO_PUBLICO }}/css/fa.min.css">
<link type="text/css" rel="stylesheet" href="{{ URL_DIRECTORIO_PUBLICO }}/css/bootstrap.min.css"/>
<style>
body {
/*Para la barra superior fija*/
padding-top: 70px;
/*Para la barra inferior fija*/
padding-bottom: 70px;
}
</style>
</head>
<body>
{% include "componentes/navbar.twig" %}
{% block contenido %}{% endblock %}
<script type="text/javascript">
// Tomado de https://github.com/parzibyte/cotizaciones_web/blob/master/js/cotizaciones.js#L2
document.addEventListener("DOMContentLoaded", () => {
const menu = document.querySelector("#menu"),
botonMenu = document.querySelector("#botonMenu");
if (menu) {
botonMenu.addEventListener("click", () => menu.classList.toggle("show"));
}
const toggleDropdown = document.querySelector("#toggleDropdown"),
dropdown = document.querySelector("#dropdown");
if (toggleDropdown) {
toggleDropdown.addEventListener("click", () => dropdown.classList.toggle("show"));
}
});
</script>
{% include "componentes/footer.twig" %}
</body>
</html>
La misma incluye otros componentes. Pero ahora veamos cómo es que otra plantilla se basa en esta maestra:
{% extends "master.twig" %}
{% block titulo %}Login
{% endblock %}
{% block contenido %}
<main class="container-fluid " role="main">
<div class="row">
<div class="col-lg-4 offset-lg-4 col-12">
<h2 class="text-center">Iniciar sesión</h2>
<form method="POST" action="{{URL_RAIZ}}/login">
{% include "componentes/sesion_flash.twig" %}
<div class="form-group">
<input class="form-control" name="correo" placeholder="Correo electrónico" required type="email">
</div>
<div class="form-group">
<input class="form-control" name="palabraSecreta" placeholder="Contraseña" required type="password">
</div>
<button class="btn btn-success" type="submit">Iniciar sesión</button>
<br>
<a class="h5 text-muted" href="{{URL_RAIZ}}/registro">Crear cuenta</a>
-
<a class="h5 text-muted" href="{{URL_RAIZ}}/usuarios/solicitar-nueva-password">Contraseña olvidada</a>
</form>
</div>
</div>
</main>
{% endblock %}
Simplemente la extendemos, y automáticamente incluirá la barra de navegación junto con el pie y otros estilos.
Mis clases
Apenas he creado dos clases mías, una llamada Redirect (que se encarga de hacer redirecciones y poner datos flash en la sesión) y otra llamada Validator, que valida los datos y si algo falla redirige hacia atrás (basada en Valitron).
<?php
namespace Parzibyte;
use Parzibyte\Servicios\SesionService;
class Redirect
{
static $ruta = "";
static $esto;
static $goBack;
private static function esto()
{
if (!self::$esto) {
self::$esto = new self();
}
return self::$esto;
}
private static function redirect($ruta, $absoluta = false)
{
$verdaderaRuta = $absoluta ? $ruta : URL_RAIZ . $ruta;
header("Location: " . $verdaderaRuta);
exit;
}
public function do() {
if (self::$goBack) {
if (isset($_SERVER["HTTP_REFERER"])) {
self::redirect($_SERVER["HTTP_REFERER"], true);
} else {
echo '<script type="text/javascript">history.go(-1)</script>';
exit;
}
}
self::redirect(self::$ruta);
}
public static function to($ruta)
{
self::$ruta = $ruta;
return self::esto();
}
public static function back()
{
self::$goBack = true;
return self::esto();
}
public static function with($datos)
{
SesionService::flash($datos);
return self::esto();
}
}
La de Validator es incluso más corta:
<?php
namespace Parzibyte;
class Validator
{
public static function validateOrRedirect($data, $rules, $route = null)
{
$validator = new \Valitron\Validator($data);
$validator->rules($rules);
if (!$validator->validate()) {
$redirect = Redirect::with([
"errores_formulario" => $validator->errors(),
]);
if ($route != null) {
$redirect->to($route);
} else {
$redirect->back();
}
$redirect->do();
}
}
}
Servicios
Tengo los servicios de sesión o de seguridad, que se encargan simplemente de encerrar funciones útiles, traer la sesión o la base de datos.
Modo de uso
Para usar esta plantilla, hay que clonarla desde GitHub. Después hay que configurar el archivo env.php
Por defecto tiene soporte para el registro de usuarios.
Comenzamos agregando rutas en el archivo rutas.php:
<?php
use Parzibyte\Servicios\SesionService;
use Parzibyte\Redirect;
use Phroute\Phroute\RouteCollector;
$enrutador = new RouteCollector();
$enrutador->filter("logueado", function () {
if (empty(SesionService::leer("correoUsuario"))) {
return Redirect::to("/login")->do();
}
});
$enrutador->filter("administrador", function () {
if (!SesionService::leer("administrador") || !SesionService::leer("idUsuario")) {
# NOTA: aquí haz la redirección a donde el usuario deba ir
return Redirect::to("/perfil/cambiar-password")->do();
}
});
$enrutador
->group(["before" => "logueado"], function ($enrutadorVistasPrivadas) {
$enrutadorVistasPrivadas
->get("/perfil/cambiar-password", ["Parzibyte\Controladores\ControladorUsuarios", "perfilCambiarPassword"])
->post("/perfil/cambiar-password", ["Parzibyte\Controladores\ControladorUsuarios", "perfilGuardarPassword"])
->get("/logout", ["Parzibyte\Controladores\ControladorLogin", "logout"]);
});
$enrutador
->group(["before" => "administrador"], function ($enrutadorVistasPrivadas) {
$enrutadorVistasPrivadas
->get("/ajustes", ["Parzibyte\Controladores\ControladorAjustes", "index"])
->get("/usuarios", ["Parzibyte\Controladores\ControladorUsuarios", "index"])
->get("/usuarios/agregar", ["Parzibyte\Controladores\ControladorUsuarios", "agregar"])
->post("/usuarios/eliminar", ["Parzibyte\Controladores\ControladorUsuarios", "eliminar"])
->get("/usuarios/eliminar/{idUsuario}", ["Parzibyte\Controladores\ControladorUsuarios", "confirmarEliminacion"])
->post("/usuarios/guardar", ["Parzibyte\Controladores\ControladorUsuarios", "guardar"]);
});
$enrutador->post("/login", ["Parzibyte\Controladores\ControladorLogin", "login"]);
$enrutador->get("/login", ["Parzibyte\Controladores\ControladorLogin", "index"]);
$enrutador->get("/registro", ["Parzibyte\Controladores\ControladorUsuarios", "registrar"]);
$enrutador->post("/usuarios/registro", ["Parzibyte\Controladores\ControladorUsuarios", "registro"]);
$enrutador->get("/usuarios/verificar/{token}", ["Parzibyte\Controladores\ControladorUsuarios", "verificar"]);
# Cuando quieren resetear
$enrutador->get("/usuarios/solicitar-nueva-password", ["Parzibyte\Controladores\ControladorUsuarios", "formularioSolicitarNuevaPassword"]);
$enrutador->post("/usuarios/solicitar-nueva-password", ["Parzibyte\Controladores\ControladorUsuarios", "solicitarNuevaPassword"]);
# Cuando ya les llegó el correo
$enrutador->get("/usuarios/restablecer-password/{token}", ["Parzibyte\Controladores\ControladorUsuarios", "formularioRestablecerPassword"]);
$enrutador->post("/usuarios/restablecer-password", ["Parzibyte\Controladores\ControladorUsuarios", "restablecerPassword"]);
# Reenviar correo de registro
$enrutador->get("/usuarios/reenviar-correo", ["Parzibyte\Controladores\ControladorUsuarios", "solicitarReenvioCorreo"]);
$enrutador->post("/usuarios/reenviar-correo", ["Parzibyte\Controladores\ControladorUsuarios", "reenviarCorreo"]);
$enrutador->get("/", ["Parzibyte\Controladores\ControladorLogin", "index"]);
return $enrutador;
Cada ruta se corresponde con un método de controlador. Ahora creamos un controlador, por ejemplo:
<?php
namespace Parzibyte\Controladores;
use Parzibyte\Modelos\ModeloUsuarios;
use Parzibyte\Redirect;
use Parzibyte\Servicios\SesionService;
use Parzibyte\Validator;
class ControladorLogin
{
public static function index()
{
if (SesionService::leer("idUsuario")) {
Redirect::to("/usuarios")->do();
}
return view("login");
}
public static function login()
{
Validator::validateOrRedirect($_POST,
[
"required" => ["correo", "palabraSecreta"],
"email" => "correo",
],
"/login");
$correo = $_POST["correo"];
$palabraSecreta = $_POST["palabraSecreta"];
$respuesta = ModeloUsuarios::login($correo, $palabraSecreta);
if ($respuesta) {
Redirect::to("/usuarios")->do();
} else {
Redirect::to("/login")->with([
"mensaje" => "Datos incorrectos",
"tipo" => "warning",
])
->do();
}
}
public static function logout()
{
SesionService::destruir();
Redirect::to("/login")->do();
}
}
Y ese controlador puede llamar a varios o un modelo. Por ejemplo el modelo de los usuarios:
<?php
namespace Parzibyte\Modelos;
use Parzibyte\Servicios\BD;
use Parzibyte\Servicios\Seguridad;
use Parzibyte\Servicios\SesionService;
use PDO;
class ModeloUsuarios
{
public static function login($correo, $palabraSecreta)
{
if (!self::coincideUsuarioYPassPorCorreo($correo, $palabraSecreta)) {
return false;
}
$usuario = self::unoPorCorreo($correo);
SesionService::escribir("correoUsuario", $correo);
SesionService::escribir("idUsuario", $usuario->id);
SesionService::escribir("administrador", boolval($usuario->administrador));
return true;
}
public static function eliminarSesiones($idUsuario)
{
$bd = BD::obtener();
$sentencia = $bd->prepare("DELETE FROM sesiones WHERE id IN (SELECT id_sesion FROM sesiones_usuarios WHERE id_usuario = ?)");
$sentencia->execute([$idUsuario]);
$sentencia = $bd->prepare("DELETE FROM sesiones_usuarios WHERE id_usuario = ?");
$sentencia->execute([$idUsuario]);
}
public static function actualizarPalabraSecreta($id, $palabraSecretaActual, $palabraSecretaNueva)
{
error_log("Se llamó a actualizar palabra secreta pero no debería");
return false;
if (!self::coincideUsuarioYPassPorId($id, $palabraSecretaActual)) {
return false;
}
$bd = BD::obtener();
$palabraSecretaCifrada = Seguridad::cifrarPalabraSecreta($palabraSecretaNueva);
$sentencia = $bd->prepare("update usuarios set palabra_secreta = ? WHERE id = ?");
return $sentencia->execute([$palabraSecretaCifrada, $id]);
}
public static function cambiarPalabraSecreta($id, $palabraSecretaNueva)
{
$bd = BD::obtener();
$palabraSecretaCifrada = Seguridad::cifrarPalabraSecreta($palabraSecretaNueva);
$sentencia = $bd->prepare("update usuarios set palabra_secreta = ? WHERE id = ?");
return $sentencia->execute([$palabraSecretaCifrada, $id]);
}
public static function agregar($correo, $palabraSecreta, $administrador = false, $cifrarPalabraSecreta = true)
{
$bd = BD::obtener();
# SQLSTATE[HY000]: General error: 1366 Incorrect integer value: '' for column 'administrador' at row 1
$administrador = $administrador ? 1 : 0;
if ($cifrarPalabraSecreta) {
$palabraSecreta = Seguridad::cifrarPalabraSecreta($palabraSecreta);
}
$sentencia = $bd->prepare("insert into usuarios(correo, palabra_secreta, administrador) values (?, ?, ?)");
return $sentencia->execute([$correo, $palabraSecreta, $administrador]);
}
private static function coincideUsuarioYPassPorCorreo($correo, $palabraSecreta)
{
$bd = BD::obtener();
$sentencia = $bd->prepare("select id, correo, palabra_secreta, administrador from usuarios WHERE correo = ?");
$sentencia->execute([$correo]);
$usuario = $sentencia->fetchObject();
if (!$usuario) {
return false;
}
return Seguridad::coinciden($palabraSecreta, $usuario->palabra_secreta);
}
public static function existePorCorreo($correo)
{
$bd = BD::obtener();
$sentencia = $bd->prepare("SELECT id FROM usuarios WHERE correo = ?");
$sentencia->execute([$correo]);
return $sentencia->fetchObject();
}
public static function coincideUsuarioYPassPorId($id, $palabraSecreta)
{
$bd = BD::obtener();
$sentencia = $bd->prepare("select id, correo, palabra_secreta, administrador from usuarios WHERE id = ?");
$sentencia->execute([$id]);
$usuario = $sentencia->fetchObject();
if (!$usuario) {
return false;
}
return Seguridad::coinciden($palabraSecreta, $usuario->palabra_secreta);
}
public static function eliminar($idUsuario)
{
$bd = BD::obtener();
$sentencia = $bd->prepare("delete from usuarios WHERE id = ?");
return $sentencia->execute([$idUsuario]);
}
public static function obtener()
{
$bd = BD::obtener();
$sentencia = $bd->prepare("select id, correo, administrador from usuarios");
$sentencia->execute();
return $sentencia->fetchAll(PDO::FETCH_OBJ);
}
public static function unoPorCorreo($correo)
{
$bd = BD::obtener();
$sentencia = $bd->prepare("select id, correo, administrador from usuarios WHERE correo = ?");
$sentencia->execute([$correo]);
return $sentencia->fetchObject();
}
public static function uno($idUsuario)
{
$bd = BD::obtener();
$sentencia = $bd->prepare("select id, correo, administrador from usuarios WHERE id = ?");
$sentencia->execute([$idUsuario]);
return $sentencia->fetchObject();
}
}
Recuerda que en varias ocasiones se invoca al método view, definido en el index:
<?php
function view($nombre, $datos = [])
{
echo Twig::obtener()->render("$nombre.twig", $datos);
return;
}
La clase Twig es de servicios y proporciona una instancia de Twig que renderiza la plantilla con los datos que le sean pasados.
Cosas faltantes
Sé que faltan algunas cosas, por ejemplo:
- Middleware
- Token CSRF
- Definición de clases padres (por ejemplo, clase Modelo)
- Autocarga de componentes (por ejemplo, la base de datos)
Conclusión
La ventaja de todo esto es que cualquiera que conozca los componentes y librerías puede trabajar con esta plantilla o caja de herramientas, ya que no obliga a tener un paradigma de programación.
el /vendor/autoload.php NO EXISTE
Hola. El /vendor/autoload.php DEBE SER GENERADO CON COMPOSER (ejecutando composer install)