Mi caja de herramientas para PHP

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.

Estoy aquí para ayudarte 🤝💻


Estoy aquí para ayudarte en todo lo que necesites. Si requieres alguna modificación en lo presentado en este post, deseas asistencia con tu tarea, proyecto o precisas desarrollar un software a medida, no dudes en contactarme. Estoy comprometido a brindarte el apoyo necesario para que logres tus objetivos. Mi correo es parzibyte(arroba)gmail.com, estoy como@parzibyte en Telegram o en mi página de contacto

No te pierdas ninguno de mis posts 🚀🔔

Suscríbete a mi canal de Telegram para recibir una notificación cuando escriba un nuevo tutorial de programación.

2 comentarios en “Mi caja de herramientas para PHP”

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *