Categoría: Ejemplos

En estos artículos podrás ver ejemplos de problemas reales y cómo fueron resueltos

  • Ejemplo de inyección de depencias en PHP

    Ejemplo de inyección de depencias en PHP

    Un concepto muy simple y, a la vez, muy potente de la Programación Orientada a Objetos es la inyección de dependencias.

    Diría que se trata de la piedra angular de cualquier sistema desacoplado y, por lo tanto, fácil de evolucionar y testear.

    Y lo mejor de todo es que su implementación es realmente sencilla. En otras palabras: puras ventajas.

    Acompañame a darle una mirada más de cerca.

    Qué es una dependencia en POO

    Como en muchas ocasiones en este mundo de la informática, un mismo término puede referirse a diferentes cosas y, para saber realmente de qué estamos hablando es necesario poner en claro el contexto.

    Por ejemplo, si hablamos de las dependencias de una aplicación, nos estaremos refiriendo a aquellas librerías necesarias para que dicha aplicación funcione. Tema que está mejor tratado en este artículo.

    En este contexto, cuando hablo de dependencia me refiero a aquellos objetos de los que otros dependen para realizar sus tareas.

    Te pongo un ejemplo que me compartió un lector de mi newsletter y me viene muy bien para ilustrar este concepto:

    <?php
    namespace Controllers;
    
    use ApplicationService\BillFinderService;
    use ApplicationService\TotalDeductiblesByYearService;
    use Dao\DeductibleDao;
    use Infraestructure\Connection\ConnectionMySql;
    
    class FrontController extends Controller{
    
        private $connection;
    
        public function __construct()
        {
            parent::__construct();
            $this->connection = new ConnectionMySql();
        }
        
        public function home($year = null){
            if (!$year) {
                $year = new \DateTime();
                $year = $year->format('Y');
            }
            $billFinderService = new BillFinderService($this->connection);
            $totalDeductibleByYear = new TotalDeductiblesByYearService($this->connection);
            $lastBillsRegistered = $billFinderService->findByYear($year);
            $totalByDeductible = $totalDeductibleByYear->getTotalByYear($year);
            echo $this->templates->render('index', [
                'title' => 'Main menu',
                'lastBillsRegistered' => $lastBillsRegistered,
                'totalByDeductible' => $totalByDeductible,
                'year' => $year,
                'years' => [2023, 2024, 2025]
            ]);
    
        }
        
        public function error404(){
            
            echo $this->templates->render('404');
        
        }
    
        public function getBillsByDeductibleIdAndYear($deductibleId, $year){
    
            $totalDeductibleByYear = new TotalDeductiblesByYearService($this->connection);
            $bills = $totalDeductibleByYear->getBillsByDeductibleIdAndYear($deductibleId, $year);
            $deductibleDao = new DeductibleDao($this->connection);
            $deductible = $deductibleDao->findById($deductibleId);
            echo $this->templates->render('bills-by-deductible-and-year', [
                'title' => "Bills by deductible by year",
                'deductibleName' => $deductible->getName(),
                'bills' => $bills,
                'year' => $year
            ]);
        }
    }

    Aquí tenemos una clase llamada FrontController que depende, entre otras, de:

    • ConnectionMySql
    • BillFinderService
    • TotalDeductiblesByYearService
    • DeductibleDao

    ¿Por qué digo que FrontController depende de estas clases? Porque, para lograr sus objetivos se sirve de métodos de ellas, ejemplo:

    public function home($year = null){
            ...
            $lastBillsRegistered = $billFinderService->findByYear($year);
            $totalByDeductible = $totalDeductibleByYear->getTotalByYear($year);
            ...
        }

    Para que este código pueda ejecutarse, será necesario que las variables que contienen referencias a dichos objetos ($billFinderService y $totalDeductibleByYear) hayan sido previamente asignadas a instancias de sus respectivas clases.

    Algo que se está haciendo precisamente al comienzo del método:

    public function home($year = null){
    ...
            $billFinderService = new BillFinderService($this->connection);
            $totalDeductibleByYear = new TotalDeductiblesByYearService($this->connection);
    ...
    }

    Entonces… ¿cuál es el problema?

    Bueno, más que un problema es una oportunidad perdida.

    Implementando inyección de dependencias

    Empiezo por una pregunta que seguramente se pasó por alto al escribir este código:

    ¿Es necesario crear una nueva instancia de cada colaborador cada vez que se llama al método?

    Ya dije que crear la instancia es inevitable pero la pregunta apunta a determinar si el mejor momento (O lugar, como quieras verlo) para hacerlo es aquí.

    ¿Qué pasaría si modificáramos ligeramente el código para que crear las dependencias fuera responsabilidad de alguien más?

    Algo como:

    class FrontController extends Controller{
    
        private $connection;
        private $billFinderService;
        private $totalDeductiblesByYearService;
    
        public function __construct(ConnectionMySql $connection, BillFinderService $billFinderService, TotalDeductiblesByYearService $totalDeductiblesByYearService)
        {
            parent::__construct();
            $this->connection = $connection;
            $this->billFinderService = $billFinderService;
            $this->totalDeductiblesByYearService = $totalDeductiblesByYearService;
        }
        
        public function home($year = null){
            if (!$year) {
                $year = new \DateTime();
                $year = $year->format('Y');
            }
            echo $this->templates->render('index', [
                'title' => 'Main menu',
                'lastBillsRegistered' => $this->billFinderService->findByYear($year),
                'totalByDeductible' => $this->totalDeductiblesByYearService->getTotalByYear($year),
                'year' => $year,
                'years' => [2023, 2024, 2025]
            ]);
        }
    ...
    }

    Con este pequeño cambio pateamos la responsabilidad hacia arriba. Ahora es problema del cliente de la clase FrontController conseguir instancias válidas para pasarle (Ya sea creándolas él mismo o pidiéndoselas a algún otro componente).

    Esta forma de vincular una clase con sus colaboradores es lo que se conoce como inyección de dependencias.

    Ventajas de la Inyección de Dependencias

    Ahora que el tecnicismo está claro, veamos por qué vale la pena diseñar de esta manera.

    Para empezar, porque el costo de hacerlo es muy bajo. Si mirás el código inicial y lo comparás con el actual diríamos que mucha diferencia no hay, ¿cierto?

    Pero vamos a ver los aspectos más jugosos del tema:

    Performance

    Este punto depende mucho del contexto particular de tu aplicación. Si el método que crea los objetos no se ejecuta con mucha frecuencia la diferencia no va a ser mucha, pero si es al revés, te vas a estar ahorrando la creación de objetos idénticos, lo que implica menos gasto de memoria y de tiempo de procesamiento.

    Es cierto que en PHP esta ventaja se diluye por el hecho de que cada request se procesa en su propio hilo pero aún así, algo se puede ganar.

    Versatilidad

    Este punto me parece el más interesante ya que es el que da lugar a los dos siguientes.

    Pensalo así: al dejar de lado la decisión de qué instancia específica del colaborador va a usar tu clase dejás abierta la puerta a que un mismo método pueda usarse en diferentes contextos.

    Imaginá un escenario en el que existe no una si no dos instancias de la clase BillFinderService y que, dependiendo de alguna configuración se necesita usar una o la otra.

    Si el código está escrito usando Inyección de Dependencias, implementar ese cambio no supone ningún inconveniente, basta con cambiar el parámetro que se usa para construir FrontController y listo.

    Es más, dado que estás trabajando con Programación Orientada a Objetos, bien podrías pasarle una instancia de una clase derivada de BillFinderService y todo seguiría funcionando como si tal cosa.

    De hecho, este mecanismo es el que posibilita mucho de los principios SOLID.

    Testabilidad

    Un caso muy claro en el que viene bien poder cambiar la instancia de un colaborador por otra es a la hora de hacer tests unitarios.

    Si no podés aislar de sus la clase que querés testear, las cosas se te van a complicar.

    Una vez que lo viste…

    … no podés des-verlo.

    No te digo que vayas corriendo a desarmar todos tus proyectos que no usan Inyección de Dependencias pero sí te propongo que le des una segunda mirada teniendo en cuenta lo que podrías ganar si lo implementás y, de a poco, lo vayas incorporando a tu día a día.

  • Un ejemplo de Laravel y React sobre Docker que funciona

    Un ejemplo de Laravel y React sobre Docker que funciona

    Cuando me llegó este mensaje

    Necesito aprender a preparar entorno de desarrollo php, laravel y react con Docker, ¿tendrás algún ejemplo para recomendarme?

    Mi primer instinto fue buscar algún tutorial para compartir. ¿Qué otra cosa podría ser mejor, no?

    Y ahí me metí a hurgar en GitHub y probar unas cuantas opciones pero ninguna parecía funcionar.

    Al menos no sin hacer unos cuantos ajustes y, dado que mi framework de referencia no es Laravel si no Symfony, me pareció que lo más lógico era seguir buscando.

    Pues bien, hurgando por aquí y por allí llegué a este tutorial que me dio (casi) justo lo que estaba buscando.

    Se trata de una sencilla app de manejo de usuarios:

    Lo interesante: está desarrollada usando Laravel y React:

    Y montada sobre Docker.

    Si querés ver los detalles de cómo logré levantar la aplicación te recomiendo que leas el tutorial original aunque debo hacer una advertencia: tuve que hacer pequeños ajustes al código para dejarla funcionando pero, ahora que está ahí, podés descargar el código del repositorio y ejecutando estos comandos desde la raíz del proyecto:

    1. docker compose up -d --wait (O su primo hermano vendor/bin/sail up -d --wait)
    2. npm run dev

    Deberías ser capaz de entrar a http://localhost y ser recibido por la aplicación.

    El objetivo de este post no es analizar el propio código (Ni PHP ni React), si no centrarme en cómo se montó todo esto sobre Docker.

    Para comprender lo que te voy a mostrar a continuación necesitás conocer los conceptos básicos de Docker. Si no los tenés frescos te recomiendo que, antes de seguir, busques aprender cómo funciona Docker en general (Tal vez este post sea un mejor lugar para arrancar).

    ¿Avanzamos?

    Los archivos de docker

    Primero que nada, una aclaración: Estos archivos los generó sail.

    No es que eso tenga nada de malo, simplemente comentar que, si usás Laravel, no parece una mala idea apoyarte en este wrapper.

    Ahora sí, el primer archivo a analizar no es otro que el docker-compose.yml

    services:
        laravel.test:
            build:
                context: './vendor/laravel/sail/runtimes/8.4'
                dockerfile: Dockerfile
                args:
                    WWWGROUP: '${WWWGROUP}'
            image: 'sail-8.4/app'
            extra_hosts:
                - 'host.docker.internal:host-gateway'
            ports:
                - '${APP_PORT:-80}:80'
                - '${VITE_PORT:-5173}:${VITE_PORT:-5173}'
            environment:
                WWWUSER: '${WWWUSER}'
                LARAVEL_SAIL: 1
                XDEBUG_MODE: '${SAIL_XDEBUG_MODE:-off}'
                XDEBUG_CONFIG: '${SAIL_XDEBUG_CONFIG:-client_host=host.docker.internal}'
                IGNITION_LOCAL_SITES_PATH: '${PWD}'
            volumes:
                - '.:/var/www/html'
            networks:
                - sail
            depends_on:
                - mysql
        mysql:
            image: 'mysql/mysql-server:8.0'
            ports:
                - '${FORWARD_DB_PORT:-3306}:3306'
            environment:
                MYSQL_ROOT_PASSWORD: '${DB_PASSWORD}'
                MYSQL_ROOT_HOST: '%'
                MYSQL_DATABASE: '${DB_DATABASE}'
                MYSQL_USER: '${DB_USERNAME}'
                MYSQL_PASSWORD: '${DB_PASSWORD}'
                MYSQL_ALLOW_EMPTY_PASSWORD: 1
            volumes:
                - 'sail-mysql:/var/lib/mysql'
                - './vendor/laravel/sail/database/mysql/create-testing-database.sh:/docker-entrypoint-initdb.d/10-create-testing-database.sh'
            networks:
                - sail
            healthcheck:
                test:
                    - CMD
                    - mysqladmin
                    - ping
                    - '-p${DB_PASSWORD}'
                retries: 3
                timeout: 5s
    networks:
        sail:
            driver: bridge
    volumes:
        sail-mysql:
            driver: local
    

    No te preocupes si te resulta algo intimidante, suele ser así cuando se ven por primera vez. Intentaré desmenuzarlo para que sea más sencillo el consumo.

    Lo primero que encontramos es que hay dos servicios: laravel.test y mysql.

    Analizaré cada uno por separado

    El servicio laravel.test

    Lo primero que se ve en la definición de este servicio es la clave build, lo que significa que se utilizará una imagen custom para crear el respectivo contenedor:

            build:
                context: './vendor/laravel/sail/runtimes/8.4'
                dockerfile: Dockerfile
                args:
                    WWWGROUP: '${WWWGROUP}'

    A partir de esta definición se pueden extraer las siguientes conclusiones:

    1. Se utilizará el archivo Dockerfile ubicado en vendor/laravel/sail/runtimes/8.4
    2. El proceso de construcción de la imagen requiere un argumento llamado WWWGROUP
    3. El valor del argumento de build se extrae de una variable de contexto llamada WWWGROUP

    Veamos el contenido del Dockerfile para hacernos una idea más clara de qué tendrá la imagen y cómo se hará la construcción:

    El archivo vendor/laravel/sail/runtimes/8.4/Dockerfile

    FROM ubuntu:24.04
    
    LABEL maintainer="Taylor Otwell"
    
    ARG WWWGROUP
    ARG NODE_VERSION=22
    ARG MYSQL_CLIENT="mysql-client"
    ARG POSTGRES_VERSION=17
    
    WORKDIR /var/www/html
    
    ENV DEBIAN_FRONTEND=noninteractive
    ENV TZ=UTC
    ENV SUPERVISOR_PHP_COMMAND="/usr/bin/php -d variables_order=EGPCS /var/www/html/artisan serve --host=0.0.0.0 --port=80"
    ENV SUPERVISOR_PHP_USER="sail"
    
    RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
    
    RUN echo "Acquire::http::Pipeline-Depth 0;" > /etc/apt/apt.conf.d/99custom && \
        echo "Acquire::http::No-Cache true;" >> /etc/apt/apt.conf.d/99custom && \
        echo "Acquire::BrokenProxy    true;" >> /etc/apt/apt.conf.d/99custom
    
    RUN apt-get update && apt-get upgrade -y \
        && mkdir -p /etc/apt/keyrings \
        && apt-get install -y gnupg gosu curl ca-certificates zip unzip git supervisor sqlite3 libcap2-bin libpng-dev python3 dnsutils librsvg2-bin fswatch ffmpeg nano  \
        && curl -sS 'https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x14aa40ec0831756756d7f66c4f4ea0aae5267a6c' | gpg --dearmor | tee /etc/apt/keyrings/ppa_ondrej_php.gpg > /dev/null \
        && echo "deb [signed-by=/etc/apt/keyrings/ppa_ondrej_php.gpg] https://ppa.launchpadcontent.net/ondrej/php/ubuntu noble main" > /etc/apt/sources.list.d/ppa_ondrej_php.list \
        && apt-get update \
        && apt-get install -y php8.4-cli php8.4-dev \
           php8.4-pgsql php8.4-sqlite3 php8.4-gd \
           php8.4-curl php8.4-mongodb \
           php8.4-imap php8.4-mysql php8.4-mbstring \
           php8.4-xml php8.4-zip php8.4-bcmath php8.4-soap \
           php8.4-intl php8.4-readline \
           php8.4-ldap \
           php8.4-msgpack php8.4-igbinary php8.4-redis \
    #       php8.4-swoole \
           php8.4-memcached php8.4-pcov php8.4-imagick php8.4-xdebug \
        && curl -sLS https://getcomposer.org/installer | php -- --install-dir=/usr/bin/ --filename=composer \
        && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
        && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_VERSION.x nodistro main" > /etc/apt/sources.list.d/nodesource.list \
        && apt-get update \
        && apt-get install -y nodejs \
        && npm install -g npm \
        && npm install -g pnpm \
        && npm install -g bun \
        && curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | gpg --dearmor | tee /etc/apt/keyrings/yarn.gpg >/dev/null \
        && echo "deb [signed-by=/etc/apt/keyrings/yarn.gpg] https://dl.yarnpkg.com/debian/ stable main" > /etc/apt/sources.list.d/yarn.list \
        && curl -sS https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor | tee /etc/apt/keyrings/pgdg.gpg >/dev/null \
        && echo "deb [signed-by=/etc/apt/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt noble-pgdg main" > /etc/apt/sources.list.d/pgdg.list \
        && apt-get update \
        && apt-get install -y yarn \
        && apt-get install -y $MYSQL_CLIENT \
        && apt-get install -y postgresql-client-$POSTGRES_VERSION \
        && apt-get -y autoremove \
        && apt-get clean \
        && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
    
    RUN setcap "cap_net_bind_service=+ep" /usr/bin/php8.4
    
    RUN userdel -r ubuntu
    RUN groupadd --force -g $WWWGROUP sail
    RUN useradd -ms /bin/bash --no-user-group -g $WWWGROUP -u 1337 sail
    
    COPY start-container /usr/local/bin/start-container
    COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
    COPY php.ini /etc/php/8.4/cli/conf.d/99-sail.ini
    RUN chmod +x /usr/local/bin/start-container
    
    EXPOSE 80/tcp
    
    ENTRYPOINT ["start-container"]

    Creo que comentar línea por línea este archivo sería un poco demasiado así que me voy a centrar en las partes más relevantes:

    1. La imagen base que se usa es ubuntu:24.04 (Link acá)
    2. Es posible especificar, además de WWWGROUP, la versión de NodeJS, el cliente de MySQL y la versión de PostgreSQL
    3. La versión de PHP que se utiliza es la 8.4
    4. Hay una cantidad de extensiones php instaladas (sqlite3, gd, mongodb, readline, etc…)
    5. XDebug viene instalado

    Respecto del argumento obligatorio WWWGROUP, se usa para establecer el id del grupo al que pertenecerá el usuario sail (el encargado de levantar el proceso php dentro del contenedor).

    El objetivo de esto es evitar los problemas de permisos que podría haber cuando Laravel requiera escribir sobre archivos que deben ser accedidos por el servidor web.

    El valor de este argumento lo provee sail.

    Este Dockerfile es, por definición, bastante genérico, para ir a producción te convendría revisarlo bien.

    En mi caso, por ejemplo, sólo utilizo MySQL, con lo cual, todo lo relativo a PostgreSQL y MongoDB no lo necesito.

    A continuación vemos

            image: 'sail-8.4/app'

    Lo que indica que la imagen que se generará llevará el nombre de sail-8.4/app

            extra_hosts:
                - 'host.docker.internal:host-gateway'

    Esta definición agrega la capacidad de conectarse desde dentro del contenedor hacia el host, fundamental para que xdebug funcione.

            ports:
                - '${APP_PORT:-80}:80'
                - '${VITE_PORT:-5173}:${VITE_PORT:-5173}'

    En esta sección se establecen los mapeos entre los puertos del host y los del contenedor, de modo de que al acceder a localhost:{$APP_PORT} y localhost:{$VITE_PORT} se pueda realizar peticiones al servidor web y al vite respectivamente.

    Los -80 y -5173 son los valores por defecto, es decir, los que se usarán a menos que haya otros especificados en variables de entorno (o en archivos .env).

            environment:
                WWWUSER: '${WWWUSER}'
                LARAVEL_SAIL: 1
                XDEBUG_MODE: '${SAIL_XDEBUG_MODE:-off}'
                XDEBUG_CONFIG: '${SAIL_XDEBUG_CONFIG:-client_host=host.docker.internal}'
                IGNITION_LOCAL_SITES_PATH: '${PWD}'

    Esta parte define algunas variables de entorno que estarán presentes cuando el contenedor se levante.

    El uso específico de cada una de ellas depende de lo que hagan los procesos instalados en el contenedor (el webserver principalmente).

            volumes:
                - '.:/var/www/html'

    Esta sección define el punto de montaje del código de la aplicación hacia el directorio /var/www/html dentro del contenedor.

            networks:
                - sail

    Aquí se define la red a la que estará conectado el contenedor (sail).

            depends_on:
                - mysql

    Y por último, se define una dependencia entre el servicio laravel.test y mysql, lo que en la práctica quiere decir que el orden de inicio de los servicios será primero mysql y luego laravel.test.

    El servicio mysql

    El segundo servicio es para la base de datos, en mi caso MySQL:

            image: 'mysql/mysql-server:8.0'
            ports:
                - '${FORWARD_DB_PORT:-3306}:3306'
            environment:
                MYSQL_ROOT_PASSWORD: '${DB_PASSWORD}'
                MYSQL_ROOT_HOST: '%'
                MYSQL_DATABASE: '${DB_DATABASE}'
                MYSQL_USER: '${DB_USERNAME}'
                MYSQL_PASSWORD: '${DB_PASSWORD}'
                MYSQL_ALLOW_EMPTY_PASSWORD: 1
            volumes:
                - 'sail-mysql:/var/lib/mysql'
                - './vendor/laravel/sail/database/mysql/create-testing-database.sh:/docker-entrypoint-initdb.d/10-create-testing-database.sh'
            networks:
                - sail
            healthcheck:
                test:
                    - CMD
                    - mysqladmin
                    - ping
                    - '-p${DB_PASSWORD}'
                retries: 3
                timeout: 5s

    A simple vista podés notar que no hay una clave build, lo que significa que se usará una imagen estándar, en este caso mysql/mysql-server:8.0 (Link acá)

    A través de la siguiente definición:

            environment:
                MYSQL_ROOT_PASSWORD: '${DB_PASSWORD}'
                MYSQL_ROOT_HOST: '%'
                MYSQL_DATABASE: '${DB_DATABASE}'
                MYSQL_USER: '${DB_USERNAME}'
                MYSQL_PASSWORD: '${DB_PASSWORD}'
                MYSQL_ALLOW_EMPTY_PASSWORD: 1

    Se establecen algunos valores necesarios para la incialización del servidor de base de datos.

            volumes:
                - 'sail-mysql:/var/lib/mysql'
                - './vendor/laravel/sail/database/mysql/create-testing-database.sh:/docker-entrypoint-initdb.d/10-create-testing-database.sh'

    Aquí se monta el volumen nombrado sail-mysql al directorio /var/lib/mysql del contenedor, con el objetivo de lograr persistencia de los datos.

    Mediante el segundo mapeo se consigue crear la base de datos como puedes ver en vendor/laravel/sail/database/mysql/create-testing-database.sh:

    #!/usr/bin/env bash
    
    mysql --user=root --password="$MYSQL_ROOT_PASSWORD" <<-EOSQL
        CREATE DATABASE IF NOT EXISTS testing;
        GRANT ALL PRIVILEGES ON \`testing%\`.* TO '$MYSQL_USER'@'%';
    EOSQL

    Aquí se define la pertenencia de este contenedor a la misma red (sail) que se utiliza en el servicio laravel.test para garantizar la conectividad entre ambos

            networks:
                - sail

    Y mediante esta configuración:

            healthcheck:
                test:
                    - CMD
                    - mysqladmin
                    - ping
                    - '-p${DB_PASSWORD}'
                retries: 3
                timeout: 5s

    Se establece un comando capaz de detectar si el servicio está funcional o no para tomar alguna acción al respecto o, al menos, poder informar de esta situación.

    Finalmente nos encontramos con la definición de las redes:

    networks:
        sail:
            driver: bridge

    Y los volúmenes nombrados:

    volumes:
        sail-mysql:
            driver: local

    Qué hacer con todo esto

    Este post, y el código que lo acompaña, están armados como un recurso didáctico, con lo cual, el mejor uso que les podés dar es estudiarlos a fondo.

    Tu tarea pues es:

    1. Descargar el código
    2. Levantar la aplicación localmente
    3. Estudiar los archivos de Docker
    4. Hacer algún pequeño cambio, por ejemplo agregar phpMyAdmin
    5. Si te animás, agregar alguna funcionalidad, ya sea al frontend o al backend
  • Primeros pasos en phpUnit

    A que adivino: apareció un bug en producción justo cuando estabas por irte a casa.

    Viendo como el plan del fin de semana se aleja en el horizonte pensaste: basta. Es hora de tener tests automatizados.

    Ok, tal vez la historia real no sea exactamente esta, pero apuesto a que estuve cerca.

    Pues bien, el primer paso obligado es instalar phpUnit.

    Esta parte es fácil:

    composer require --dev phpunit/phpunit

    Ojo con esto, verificá que la versión de phpunit sea compatible con tu versión de php.

    Para este post asumiré que estás trabajando sobre php 8.3, con lo cual, la versión de phpunit que te corresponde es la 11. Es decir que el comando correcto sería:

    composer require --dev phpunit/phpunit ^11

    Antes de continuar, verificá que está todo en su lugar con:

    ./vendor/bin/phpunit --version

    Si ves algo como:

    PHPUnit 11.0.0 by Sebastian Bergmann and contributors.

    Está todo listo para el siguiente paso: escribir un test.

    Acá hay unas cuantas posibilidades dependiendo de la naturaleza de tu aplicación pero, para dejar este post de un tamaño razonable voy a hacer unas cuantas suposiciones (Si querés explorar algún caso más particular dejame un comentario).

    Vamos a imaginar que querés testear una clase llamada EmailValidator que, oh sorpresa, se encarga de determinar si un string constituye o no, un correo válido.

    Voy a poner el foco en el test, con lo cual, no me importa en este momento si EmailValidator hace lo que tiene que hacer o no, lo que me importa es poder escrbir un test que me permita verificarlo en todo momento.

    Tu primer test con phpUnit

    Como dije antes, voy a hacer unas cuantas suposiciones. Una de ellas es que tu proyecto está estructurado de un modo similar a:

    /src
    --/EmailValidator.php
    /tests
    composer.json

    Y que el archivo composer.json define algún tipo de autoloading, con lo cual, un archivo tests/EmailValidatorTest.php que se va así debería funcionar:

    <?php
    
    declare(strict_types=1);
    
    use PHPUnit\Framework\TestCase;
    use PHPUnit\Framework\Attributes\Test;
    
    class EmailValidatorTest extends TestCase
    {
        #[Test]
        public function do_something(): void {
            $this->assertTrue(true);
        }
    }

    Este primer test, como te habrás dado cuenta, es tautológico, no te preocupes, no lo vamos a dejar así, es un paso temporal para validar que el framework de testing está bien configurado.

    Ejecutalo con:

    ./vendor/bin/phpunit tests

    Y si ves algo como

    PHPUnit 11.4.2 by Sebastian Bergmann and contributors.
    
    Runtime:       PHP 8.4.0RC1
    
    .                                                                   1 / 1 (100%)
    
    Time: 00:00.003, Memory: 6.00 MB
    
    OK (1 test, 1 assertion)

    Está todo en orden y podés pasar a la lógica de la verificación.

    Ahora bien… ¿qué debería validar el test? Bueno… esto puede ponerse algo filosófico, por ahora lo dejo bien concreto: lo que debe verificar es que EmailValidator responde «sí» (o true) cuando se le pide validar un email y «no» (o false) cuando el parámetro es un string no válido como email.

    En otras palabras: el test debe asegurar, con un umbral razonable de confianza, que la clase EmailValidator es capaz de reconocer direcciones de correo electrónico o, tal vez, es capaz de determinar si un string es o no un correo electrónico.

    En todo caso, el test podría escribirse de este modo:

    <?php
    
    declare(strict_types=1);
    
    use App\EmailValidator;
    use PHPUnit\Framework\TestCase;
    use PHPUnit\Framework\Attributes\Test;
    
    class EmailValidatorTest extends TestCase
    {
        #[Test]
        public function should_determine_whether_a_string_is_a_valid_email(): void {
            $validator = new EmailValidator();
            $this->assertTrue($validator->isValid("mauro.chojrin@leewayweb.com"));
        }
    }

    Y, al ejecutarlo, lo esperable es que pase. Salvo, por supuesto, que el EmailValidator no esté correctamente implementado, algo que, para este ejemplo, asumiré que no es el caso.

    Bien, con esto tenemos cubierto el caso positivo (Al menos uno de ellos), faltaría cubrir el caso negativo, es decir, que el EmailValidator sabe reconocer falsos correos.

    Para eso podríamos hacer algo como:

    <?php
    
    declare(strict_types=1);
    
    use App\EmailValidator;
    use PHPUnit\Framework\TestCase;
    use PHPUnit\Framework\Attributes\Test;
    
    class EmailValidatorTest extends TestCase
    {
        #[Test]
        public function should_determine_whether_a_string_is_a_valid_email(): void {
            $validator = new EmailValidator();
            $this->assertTrue($validator->isValid("mauro.chojrin@leewayweb.com"));
            $this->assertFalse($validator->isValid("mauro.chojrin.leewayweb.com"));
        }
    }

    No está necesariamente mal tener más de un assertion por test aunque, personalmente, preferiría separar los datos de la lógica del test.

    Para eso, la recomendación es usar un test parametrizado:

    <?php
    
    declare(strict_types=1);
    
    use App\EmailValidator;
    use PHPUnit\Framework\Attributes\DataProvider;
    use PHPUnit\Framework\TestCase;
    use PHPUnit\Framework\Attributes\Test;
    
    class EmailValidatorTest extends TestCase
    {
        public static function dataProvider(): array
        {
            return [
                [ "mauro.chojrin@leewayweb.com", true ],
                [ "mauro.chojrin.leewayweb.com", false ],
            ];
        }
    
        #[Test]
        #[DataProvider("dataProvider")]
        public function should_determine_whether_a_string_is_a_valid_email(string $candidate, bool $isEmail): void {
            $validator = new EmailValidator();
            $this->assertEquals($isEmail, $validator->isValid($candidate));
        }
    }

    De esta forma, cuando quieras agregar casos de prueba bastará con modificar el método dataProvider, por ejemplo así:

        public static function dataProvider(): array
        {
            return [
                [ "mauro.chojrin@leewayweb.com", true ],
                [ "mauro.chojrin+1@leewayweb.com", true ],
                [ "mauro.chojrin.leewayweb.com", false ],
                [ "mauro.chojrin   @leewayweb.com", false ],
            ];
        }

    Y así podés seguir construyendo más y más casos de prueba hasta que te sientas seguro de que la implementación de la clase cumple sus requerimientos.

    Claro que hay mucho (¡mucho!) más para aprender pero bueno… por algún lado hay que empezar, ¿no?

    Por si tenés problemas en el camino

    Algunas cosas que pueden fallarte en el camino y que son simples de resolver:

    Faltan extensiones de php

    phpUnit requiere de estas extensiones:

    • ext-dom
    • ext-mbstring

    Dependiendo de tu ambiente de trabajo es posible que no se encuentren disponibles apenas arrancás. Si este es el caso, las podrás instalar usando el manejador de paquetes de tu sistema operativo.

    No se reconocen las clases productivas

    Es posible que la primera vez que ejecutes tu código te encuentres con un problema como este:

    PHPUnit 11.4.2 by Sebastian Bergmann and contributors.
    
    Runtime:       PHP 8.4.0RC1
    Configuration: /home/mauro/Code/phpunit101/phpunit.xml
    
    EE                                                                  2 / 2 (100%)
    
    Time: 00:00.006, Memory: 8.00 MB
    
    There were 2 errors:
    
    1) EmailValidatorTest::should_determine_whether_a_string_is_a_valid_email with data set #0 ('mauro.chojrin@leewayweb.com', true)
    Error: Class "App\EmailValidator" not found
    
    /home/mauro/Code/phpunit101/tests/EmailValidatorTest.php:23
    
    2) EmailValidatorTest::should_determine_whether_a_string_is_a_valid_email with data set #1 ('mauro.chojrin.leewayweb.com', false)
    Error: Class "App\EmailValidator" not found
    
    /home/mauro/Code/phpunit101/tests/EmailValidatorTest.php:23
    
    ERRORS!
    Tests: 2, Assertions: 0, Errors: 2.
    

    Si eso pasa, verificá cómo está configurado tu autoloading en tu archivo composer.json. Debería verse así:

    {
      "autoload": {
        "psr-4": {
          "App\\": "src/"
        }
      }
      "require-dev": {
        "phpunit/phpunit": "^11"
      }
    }

    También es posible que necesites crear el archivo phpunit.xml en la raíz de tu proyecto con este contenido:

    <phpunit
       bootstrap="vendor/autoload.php"
    />

    Y para estar 100% seguro, no está de más ejecutar:

    composer dump-autoload

    Con esto deberías tener todo lo necesario.

    Por dónde seguir

    La documentación oficial de phpUnit siempre es una buena fuente.

    Te recomiendo mirarte especialmente la parte de la configuración y los dobles de test.

    ¡A testear se ha dicho!

  • XDebug con VSCode y Docker en Ubuntu

    Usar Docker en proyectos PHP es un viaje de ida.

    Olvidarse del «te juro que en mi casa andaba!» es una bendición.

    Claro que, para poder desplegar, primero hay que desarrollar. Y desarrollar implica, claro está, debuggear.

    En PHP no contamos con un debugger incorporado a nuestros IDEs… afortunadamente existe XDebug.

    El problema, sin embargo, suele ser la configuración que depende de dos factores que deben combinarse:

    1. La instalación y configuración del lado del servidor.
    2. La configuración del IDE para poder utilizarlo.

    Existen muchas combinaciones posibles para realizar esta tarea, cada una con sus pequeñas particularidades. En este artículo me enfocaré en una de las combinaciones más populares por estos días:

    VSCode sobre Ubuntu con un WebServer montado en Docker.

    La aplicación que usaré como ejemplo es el CRM de código abierto rukovoditel.

    El Dockerfile

    Lo primero será definir el archivo Dockerfile que permita contar con un servidor web que pueda ejecutar php y, a la vez, tenga instalado XDebug:

    FROM php:7.4-apache
    LABEL authors="mauro.chojrin@leewayweb.com"
    
    RUN apt-get update && \
        apt-get install -y \
            libpng-dev \
            libzip-dev && \
            pecl install xdebug-3.1.6
    
    RUN docker-php-ext-install gd && \
        docker-php-ext-install zip && \
        docker-php-ext-install mysqli && \
        docker-php-ext-enable xdebug
    
    COPY xdebug.ini /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
    
    ADD app /var/www/html
    
    RUN chown -R www-data.www-data /var/www/html

    La configuración de XDebug

    A continuación ha de crearse el archivo de configuración de xdebug (xdebug.ini) en la raíz del proyecto:

    zend_extension=xdebug
    
    [xdebug]
    xdebug.mode=develop,debug
    xdebug.client_host=host.docker.internal
    xdebug.start_with_request=yes

    El archivo docker-compose.yml

    Posteriormente será el turno del archivo docker-compose.yml que contendrá los servicios necesarios para dar vida a la aplicación:

    version: '3.1'
    services:
      mysql:
        image: 'mysql:5.7.42-debian'
        environment:
          - MYSQL_ROOT_PASSWORD=root
          - MYSQL_DATABASE=ruko
          - MYSQL_USER=ruko
          - MYSQL_PASSWORD=ruko
        restart: always
      webserver:
        image: 'ruko_ws'
        build:
          context: '.'
        ports:
          - '8888:80'
        volumes:
          - './app:/var/www/html:rw'
          - './xdebug.ini:/usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini'
        extra_hosts:
          - host.docker.internal:host-gateway

    Con esto está listo todo lo necesario del lado de la infraestructura.

    Los siguientes pasos se darán del lado del host.

    Configuración de VS Code

    La extensión de debugging de PHP

    Una vez abierto el proyecto, hay que asegurarse de tener instalada la extensión requerida para realizar debugging:

    La configuración de lanzamiento

    A continuación, debe crearse una configuración de lanzamiento para realizar el debugging:

    De las múltiples entradas que encuentro la que interesa es la llamada Listen for Xdebug.

    Por defecto, lo que se ve es:

    {
          "name": "Listen for Xdebug",
          "type": "php",
          "request": "launch",
          "port": 9003
    },

    Lo que se necesita es agregar la definición del mapeo de directorios, desde el local al del servidor.

    En este caso, el directorio en el servidor (en el contenedor Docker) es /var/www/html/, el cual debe ser conectado con el directorio app dentro de la raíz del proyecto.

    Para usar el directorio del host, sin hardcodearlo, es posible usar la variable de VisualStudio workspaceFolder la cual contiene el directorio raíz del proyecto.

    De modo que la configuración quedará de esta forma:

    {
          "name": "Listen for Xdebug",
          "type": "php",
          "request": "launch",
          "port": 9003,
          "pathMappings": {
                 "/var/www/html/": "${workspaceFolder}/app"
          }
    },

    Debuggeando

    Con esto listo es posible arrancar el debugger:

    Para ver su efecto es conveniente introducir un punto de interrupción (breakpoint) en algún lugar del código.

    Tomemos como ejemplo el archivo app/install/index.php ya que es el primero que se ejecuta al ingresar a la aplicación.

    Lo próximo será ir al navegador web, donde se dará comienzo a la sesión de debugging usando el asistente de XDebug.

    Luego se debe ingresar la dirección de la página (http://localhost:8888 en este caso) y, al dar enter se abrirá automáticamente la ventana de VS Code:

    Donde podrá ejecutarse el código paso a paso:

    Inspeccionar variables en tiempo real

    Y, en general, utilizar todos los servicios provistos por XDebug.

    Puntos clave

    En resúmen, las claves para hacer funcionar XDebug con VSCode en Docker sobre Ubuntu son:

    1. Contar con una imagen basada en un Dockerfile que incluya la instalación de XDebug
    2. Tener instalada la extensión de debugging con PHP en VS Code
    3. Configurar correctamente el mapeo de directorios en la configuración de lanzamiento (launch.json)
    4. Tener la definición de extra_hosts del contenedor que tiene php apuntando a host-gateway
    5. Tener el xdebug correctamente configurado dentro del contenedor de php
  • Integrar dos aplicaciones con un WebService SOAP

    Integrar dos aplicaciones con un WebService SOAP

    Una consulta que recibo bastante a menudo remite a cómo conectar dos sistemas dentro de una organización.

    Algunos ejemplos:

    Tengo la necesidad de conectar dos sistemas, uno de administración de stock y otro de asignaciones de bienes a personal. Lo que debería suceder son dos cosas:

    1. Sistema de administración hace compra de stock, por lo cual deberá informarle al otro sistema que debe actualizar las cantidades.

    2Sistema de asignación de bienes a personal hace entrega de un producto a un empleado, por lo cual deberá informarle al otro sistema que descuente del stock las cantidades otorgadas.

    El problema principal que tengo es que no hay posibilidad de modificar el sistema de asignación por lo cual tengo que desarrollar los web services intermedios para poder comunicarme con él a través de archivos planos.

    Tengo dos plataformas, las llamaré A y B.

    A es una nueva plataforma web entre nuestros clientes y nosotros, con un formulario para configurar un objeto. (con una series de atributos)

    B es el Product Data Management. Es la herramienta interna que usamos para gestionar estos objetos (crear/actualizar, etc…). Cada objeto también tiene una serie de atributos.

    La idea es de recuperar información de la plataforma A y usarla para generar/actualizar automáticamente un objeto en B, usando los web services de B.

    Creo que ya debe haber quedado claro que la problemática en todos los casos es similar.

    Conectar dos sistemas no es precisamente una tarea trivial.

    Más aún si están desarrollados usando diferentes tecnologías.

    Más aún si están hosteados en diferentes redes.

    En definitiva estamos en una de dos situaciones:

    • Un caso super sencillo en el que basta usar un recurso compartido (Un archivo, una base de datos, un repositorio de memoria, etc…).
    • Tenemos que pensar en una implementación basada en WebServices.

    Es más, mi recomendación es que, aunque hoy la solución pueda basarse en un esquema simple, vale la pena hacer el esfuerzo extra para preparar la conexión de ambos sistema para otros escenarios menos favorables.

    Al fin y al cabo, el esfuerzo tampoco será tan grande.

    Qué se necesita para conectar dos sistemas mediante SOAP

    Básicamente lo que se necesitará es implementar, al menos, un cliente y un servidor.

    Esto no necesariamente tendrá un impacto elevado en cuanto a cambios en la infraestructura.

    Ni siquiera tiene por qué suponer una modificiación sustancial en las aplicaciones aunque, para dar seguridad habría que ver qué tan bien estructurado está el código.

    En definitiva, lo que se necesitará es dotar a la aplicación receptora de la información de un mecanismo para exponer un webservice SOAP y a la emisora de uno para consumirlo.

    Esto puede sonar un poco contra-intuitivo, pero debe pensarse como que el receptor es quien brinda el servicio y el emisor quien lo utiliza a modo de cliente.

    Un ejemplo de conexión via SOAP

    Voy a tomar una versión simplificada del primer caso para realizar el ejemplo.

    Asumo que contamos con una base de datos con esta estructura:

    Sistema de compras
    Sistema de asignación de productos

    Dejo de lado una serie de detalles que, si bien son sumamente importantes, harían el ejemplo tan complejo que se perdería lo fundamental:

    1. No realizaré verificaciones de consistencia (De cantidades asignadas vs. compradas, de SKUs entre aplicaciones, etc…).
    2. Asumiré que la seguridad de la comunicación se maneja en alguna otra capa.

    Ahora sí, comencemos.

    Informando al sistema de asignación sobre nuevas compras

    Desde el lado del sistema de compras, lo que se necesita es enviar información al sistema de asignación, para lo cual, éste debe estar preparado para recibirlos.

    Para no entrar en un dilema de huevo-gallina, arranco por el lado del cliente asumiendo que el lado servidor está disponible y luego paso a mostrarlo.

    El código del cliente se podría parecer a esto:

    <?php
    
    require_once 'db.config.php';
    
    $mysqli = new mysqli($db_host, $db_user, $db_password, $db_name);
    
    $sql = "SELECT pu.quantity, pr.SKU FROM purchases pu INNER JOIN products pr ON pr.id = pu.product_id;";
    
    $reuslt = $mysqli->query($sql);
    
    $client = new SoapClient('purchases.wsdl');
    
    foreach ($reuslt->fetch_array(MYSQLI_ASSOC) as $purchaseRecord) {
        if ( 1 != $client->save_purchase(
            $purchaseRecord['SKU'],
            $purchaseRecord['quantity']
        ) ) {
             die ('Failed to save update of '.$purchaseRecord['SKU']);
        }
    }

    Este código podría ejecutarse dentro de una tarea cron, incorporarse como parte del proceso de registro de una compra o como respuesta a una acción específica del usuario.

    Todo dependerá del contexto general en el que operen los sistemas en cuestión.

    Asentando información de nuevas compras en el sistema de asignación

    Del lado del servidor (el sistema de asignación en este caso), encontraríamos un código similar a:

    <?php
    
    function save_purchase(string $SKU, int $quantity) : int
    {
        $sql = "UPDATE products SET stock = stock + ".$quantity." WHERE sku = '$SKU'";
    
        $config = require_once 'config.php';
    
    $mysqli = new mysqli($config['db_host'], $config['db_user'], $config['db_password'], $config['db_name']);
    
    
        return $mysqli->query($sql) ? 1: 0;
    }
    
    $server = new SoapServer("purchase.wsdl");
    
    $server->addFunction('save_purchase');
    $server->handle();

    Este código deberá estar alojado en alguna URL a la que pueda acceder el cliente, en este caso, el sistema de compras.

    Y, en ambos lados se encontrará el archivo de definición del webservice que se verá similar a:

    <!--
    Leeway
    2022-07-26
    A service to record new product purchases
      -->
    <definitions xmlns:tns="com.leewayweb.academy.wsdl" xmlns:soap="http://schemas.xmlsoap.org/wsdl/soap/"
                 xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsd1="com.leewayweb.academy.xsd"
                 xmlns:soapenc="http://schemas.xmlsoap.org/soap/encoding/" xmlns:wsdl="http://schemas.xmlsoap.org/wsdl/"
                 xmlns="http://schemas.xmlsoap.org/wsdl/" name="A service to record new product purchases"
                 targetNamespace="com.leewayweb.academy.wsdl">
        <!--  definition of datatypes  -->
        <types>
            <schema xmlns="http://www.w3.org/2000/10/XMLSchema" targetNamespace="com.leewayweb.academy.xsd">
                <element name="SKU">
                    <complexType>
                        <all>
                            <element name="value" type="string"/>
                        </all>
                    </complexType>
                </element>
                <element name="quantity">
                    <complexType>
                        <all>
                            <element name="value" type="int"/>
                        </all>
                    </complexType>
                </element>
                <element name="result">
                    <complexType>
                        <all>
                            <element name="value" type="int"/>
                        </all>
                    </complexType>
                </element>
            </schema>
        </types>
        <!--  response messages  -->
        <message name="returns_result">
            <part name="result" type="xsd:result"/>
        </message>
        <!--  request messages  -->
        <message name="save_purchase">
            <part name="SKU" type="xsd:SKU"/>
            <part name="quantity" type="xsd:quantity"/>
        </message>
        <!--  server's services  -->
        <portType name="purchases">
            <operation name="save_purchase">
                <input message="tns:save_purchase"/>
                <output message="tns:returns_result"/>
            </operation>
        </portType>
        <!--  server encoding  -->
        <binding name="purchases_webservices" type="tns:purchases">
            <soap:binding style="rpc" transport="http://schemas.xmlsoap.org/soap/http"/>
            <operation name="save_purchase">
                <soap:operation soapAction="urn:xmethods-delayed-quotes#save_purchase"/>
                <input>
                    <soap:body use="encoded" namespace="urn:xmethods-delayed-quotes"
                               encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"/>
                </input>
                <output>
                    <soap:body use="encoded" namespace="urn:xmethods-delayed-quotes"
                               encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"/>
                </output>
            </operation>
        </binding>
        <!--  access to service provider  -->
        <service name="academy">
            <port name="academy_0" binding="purchases_webservices">
                <soap:address location="http://127.0.0.1/purchases.php"/>
            </port>
        </service>
    </definitions>

    Informando al sistema de compras sobre la insuficiencia de stock

    Ahora entonces toca hacer algo muy similar pero visto desde el otro lado:

    <?php
    
    $client = new SoapClient("stock.wsdl");
    
    $config = require_once 'config.php';
    
    $mysqli = new mysqli($config['db_host'], $config['db_user'], $config['db_password'], $config['db_name']);
    
    $sql = "SELECT SKU FROM products WHERE stock < minimum;";
    
    $result = $mysqli->query($sql);
    
    foreach ($result->fetch_array() as $product) {
        $product = $result->fetchArray();
        $client->generatePurchaseOrder($product['SKU']);
    }

    Este script podría, como vimos antes, ejecutarse periódicamente, o como resultado de una acción iniciada por el usuario (Lo que mejor se adapte a las necesidades del negocio).

    Generando la orden de compra

    Del lado del sistema de compras nos encontraremos con algo como:

    <?php
    
    function create_purchase_order(string $SKU) : int
    {
        $sql = "INSERT INTO purchase_orders (product_id, quantity) SELECT id, purchase_quantity FROM products WHERE SKU = '$SKU';";
    
        $config = require_once 'config.php';
        $mysqli = new mysqli($config['db_host'], $config['db_user'], $config['db_password'], $config['db_name']);
    
        return $mysqli->query($sql) ? 1: 0;
    }
    
    $server = new SoapServer("stock.wsdl");
    
    $server->addFunction('create_purchase_order');
    $server->handle();

    Y, no olvidar, el archivo WSDL:

    <?xml version="1.0" encoding ="utf-8"?>
    <!--
    Leeway
    2022-07-28
    Creates pruchase orders for products below critical point
     -->
    <definitions name="Creates pruchase orders for products below critical point"
                 targetNamespace="com.leewayweb.wsdl"
                 xmlns:tns="com.leewayweb.wsdl"
                 xmlns:soap="http://schemas.xmlsoap.org/wsdl/soap/"
                 xmlns:xsd="http://www.w3.org/2001/XMLSchema"
                 xmlns:xsd1="com.leewayweb.xsd"
                 xmlns:soapenc="http://schemas.xmlsoap.org/soap/encoding/"
                 xmlns:wsdl="http://schemas.xmlsoap.org/wsdl/"
                 xmlns="http://schemas.xmlsoap.org/wsdl/">
        <!-- definition of datatypes -->
        <types>
            <schema targetNamespace="com.leewayweb.xsd" xmlns="http://www.w3.org/2000/10/XMLSchema">
                <element name="SKU">
                    <complexType>
                        <all>
                            <element name="value" type="string"/>
                        </all>
                    </complexType>
                </element>
                <element name="resultcode">
                    <complexType>
                        <all>
                            <element name="value" type="int"/>
                        </all>
                    </complexType>
                </element>
            </schema>
        </types>
        <!-- response messages -->
        <message name='returns_resultcode'>
            <part name='resultcode' type='xsd:resultcode'/>
        </message>
        <!-- request messages -->
        <message name='create_purchase_order'>
            <part name='SKU' type='xsd:SKU'/>
        </message>
        <!-- server's services -->
        <portType name='purchase_orders'>
            <operation name='create_purchase_order'>
                <input message='tns:create_purchase_order'/>
                <output message='tns:returns_resultcode'/>
            </operation>
        </portType>
        <!-- server encoding -->
        <binding name='purchase_orders_webservices' type='tns:purchase_orders'>
            <soap:binding style='rpc' transport='http://schemas.xmlsoap.org/soap/http'/>
            <operation name='create_purchase_order'>
                <soap:operation soapAction='urn:xmethods-delayed-quotes#create_purchase_order'/>
                <input>
                    <soap:body use='encoded' namespace='urn:xmethods-delayed-quotes'
                               encodingStyle='http://schemas.xmlsoap.org/soap/encoding/'/>
                </input>
                <output>
                    <soap:body use='encoded' namespace='urn:xmethods-delayed-quotes'
                               encodingStyle='http://schemas.xmlsoap.org/soap/encoding/'/>
                </output>
            </operation>
        </binding>
        <!-- access to service provider -->
        <service name='academy'>
            <port name='academy_0' binding='purchase_orders_webservices'>
                <soap:address location='http://127.0.0.1./purchase_orders.php'/>
            </port>
        </service>
    </definitions>

    Resumiendo

    Integrar dos sistemas puede ser una tarea compleja.

    Mucho dependerá del conocimiento que se tenga sobre la organización de la información en cada uno de ellos, la infraestructura que les dé soporte y demás.

    Independientemente de las dificultades técnicas que esto suponga, la integración automática de sistemas de información supone para muchas empresas una mejora sustancial en la eficiencia de sus procesos de negocio (Imagina lo que significa trasladar inforamción manualmente de una base de datos a otra).

    Personalmente, antes de meter una capa extra de complejidad como la que supone utilizar SOAP intentaría diseñar una solución basada en REST.

    Claro que, muchas veces no es posible tomar esta decisión porque otra persona ya lo hizo por nosotros.

    En tal caso la solución pasará por utilizar las mejores herramientas de las que podemos disponer.

    Para este ejemplo usé este sitio para generar el WSDL ya que se trataba de servicios simples, pero para un caso más realista intentaría usar una herramienta como esta.

  • Por qué tus páginas con DataTables tardan tanto en cargar

    Por qué tus páginas con DataTables tardan tanto en cargar

    Una página que tarda mucho en cargar es una página mala.

    Lo sabés vos y lo saben tus usuarios.

    Si tu sitio tarda más de 5 segundos en mostrarse tus visitantes se sentirán así:

    Las páginas que usan DataTables se ven bien y son muy funcionales pero pueden tardar una eternidad en desplegarse.

    Más de la mitad de los usuarios que no encuentran pronto lo que buscan se van a otro sitio.

    Y los que se quedan desconfían de la profesionalidad de quien las desarrolló.

    ¿Y quién quiere contratar a alguien que entrega trabajos de dudosa calidad?

    Más vale dedicar unos minutos a aprender cómo mejorarlas.

    Factores que afectan el tiempo de carga de tu página

    En su forma más simple, un DataTable puede cargarse usando un arreglo JavaScript.

    Basta un código como este:

    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <link rel="stylesheet" href="//cdn.datatables.net/1.10.24/css/jquery.dataTables.min.css">
        <title>DataTables example</title>
    </head>
    <body>
    <h1>Behold... the power of DataTables!</h1>
    <table id="theTable" class="display" style="width: 100%">
        <thead>
            <tr>
                <th>Id</th>
                <th>Name</th>
                <th>Price</th>
            </tr>
        </thead>
        <tfoot>
            <tr>
                <th>Id</th>
                <th>Name</th>
                <th>Price</th>
            </tr>
        </tfoot>
    </table>
    </body>
    </html>
    <script src="https://code.jquery.com/jquery-3.6.0.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>
    <script src="https://cdn.datatables.net/1.10.24/js/jquery.dataTables.min.js"></script>
    <script type="application/javascript">
        $(document).ready( function () {
            $('#theTable').DataTable({
                'data': [
                    [1, 'Box', 10.0],
                    [2, 'Chair', 20.2]
                ],
            });
        } );
    </script>

    Por supuesto que si toda la información tuviese que estar hardcodeada no sería muy útil la aplicación, ¿cierto?

    Una versión algo más realista sería combinar este código con algo de PHP que levante información de una db, por ejemplo:

    <?php
    
    try {
        $conn = new PDO('sqlite:dt.sq3');
    } catch (PDOException $exception) {
        die($exception->getMessage());
    }
    
    $sql = "SELECT * FROM products";
    $st = $conn
    ->query($sql);
    
    if ($st) {
    $rs = $st->fetchAll(PDO::FETCH_ASSOC );
    ?>
    <script type="application/javascript">
        $(document).ready( function () {
            $('#theTable').DataTable({
                'data': [
                    <?php
                    foreach ($rs as $row) {
                        ?>
                    [ '<?php echo $row['id'];?>', '<?php echo $row['name'];?>', '<?php echo $row['price'];?>' ],
                        <?php
                    }
                    ?>
                ],
            });
        } );
    </script>
    <?php
    } else {
        var_dump($conn->errorInfo());
        die;
    }

    Hasta aquí, todo perfecto.

    El problema se presenta cuando la cantidad de registros es grande.

    La definición de grande es un poco esquiva… podés empezar a notar problemas con 500 registros pero si estás tratando con algo como 150 000 o más definitivamente tus páginas van a tardar una eternidad en mostrarse.

    Aún si tenés páginas pequeñas (Digamos, de 10 registros cada una), el tiempo total de carga no se modifica en absoluto…

    ¿Por qué sucede esto?

    Como en cualquier otro script, esto es, a grosso modo lo que ocurre:

    1. El servidor recibe la petición
    2. Se ejecuta el PHP
    3. Se realiza la consulta a la db
    4. El servidor recibe todos los registros de la tabla
    5. Se genera el HTML y el JavaScript
    6. Se envía todo al cliente
    7. El cliente interpreta el HTML y el JavaScript
    8. Se ejecuta el JavaScript y se dibuja la tabla y todo lo demás

    Como te podrás imaginar, para trabajar con los registros de la primera página no es realmente necesario tener en memoria las otras 500…

    Toda esa información que no se utilizará no hace más que sobrecargar tanto al servidor (especialmente al de bases de datos) como al cliente.

    Es más, si hay muchos usuarios accediendo a la vez el problema se va multiplicando, con lo cual la experiencia de uso es peor para todos 🙁

    Podrías intentar atacar el problema mejorar los recursos de hardware, implementando algún tipo de caché o similar y es posible que todo eso ayudara.

    Sin embargo, existe una solución mucho más al alcance de tus manos.

    La solución: Procesamiento del lado del Servidor

    Y aquí viene la genialidad de DataTables… ¡lo pensaron todo estos muchachos!

    Este plugin admite dos modos de procesamiento: Client-Side (Es decir, todo se hace en el navegador del cliente) o Server-Side (El procesamiento se hace del lado del servidor y sólo se realiza el rendering del lado del cliente).

    El modo por defecto es, por supuesto, Client-Side.

    He ahí la clave del problema.

    Trabajar con Server-Side es un poco más complicado, pero tampoco es terrible.

    Lo más importante es comprender que, en este modo, DataTables hará una llamada Ajax cada vez que se requiera re-dibujar la información de la tabla (Al paginar, ordenar, etc…).

    Para comenzar a usar este modo necesitás cambiar la configuración de tu DataTable por algo como:

    $('#theTable').DataTable( {
        serverSide: true,
        ajax: '/get_data.php'
    } );

    Claro que, a diferencia del ejemplo en que la tabla se carga por Ajax sólo al comienzo (o cuando se refresca), esta versión de get_data.php deberá ser bastante más inteligente para poder responder a las órdenes que le dará el FrontEnd.

    Qué envía el FrontEnd al BackEnd en DataTables Server-Side

    La comunicación entre FrontEnd y BackEnd se realizará a través del envío de parámetros a través de la URL de la llamada (salvo que lo cambies explícitamente).

    Esto implica que tu script php deberá recogerlos desde $_GET.

    A modo de resumen te comento los más importantes (La lista completa la podés encontrar acá):

    start: Número de registro por el que debe comenzar la paginación

    length: Cantidad de registros que se espera que devuelva el servidor. Es decir, a partir del registro start, retorná length registros (Similar al LIMIT X OFFSET Y de SQL)

    search: Arreglo que contiene los parámetros de la búsqueda realizada por el usuario. Esta búsqueda es global, es decir, aplica a todas aquellas columnas marcadas como searchable.

    • value: El texto ingresado por el usuario en el cuadro de búsqueda
    • regex: Booleano que indica si el value debe ser interpretado en forma literal o como expresión regular

    columns: Arreglo con una entrada por cada columna de la tabla.

    Cada entrada de este arreglo será, a su vez, un arreglo con las siguientes claves:

    • data: información de mapeo entre el arreglo de datos y la tabla HTML (En este ejemplo se trata del índice que vincula la columna en la visualización con el campo en la consulta: id => 0, name => 1, price => 2)
    • name: Nombre de la columna como está definido en la propiedad columns.name (En este ejemplo no lo estamos usando así que será un string vacío).
    • searchable: Booleano que indica si es posible realizar búsquedas sobre esta columna
    • orderable: Booleano que indica si es posible realizar ordenamientos basados en esta columna
    • search: Arreglo que contiene los parámetros de la búsqueda realizada por el usuario. Similar a la búsqueda global, sólo que aplicada a esta columna en particular (Un caso algo más avanzado y no muy frecuentemente utilizado).
      • value: El texto ingresado por el usuario en el cuadro de búsqueda
      • regex: Booleano que indica si el value debe ser interpretado en forma literal o como expresión regular

    order: Arreglo que especifica el criterio de ordenamiento que debe aplicarse, contendrá una entrada por cada criterio a utilizar (Para el caso de que se quieran combinar varios). Dentro de cada entrada contendrá un arreglo con dos claves:

    1. column: Número de columna por la que se quiere ordenar
    2. dir: String con la dirección del ordenamiento (asc para ascendente, desc para descendiente)

    Como te podrás imaginar, la URL de la llamada puede ser algo extensa 🙂

    ¿No me creés? ¿Qué te parece esta?

    http://localhost:8000/get_data.php?draw=1&columns%5B0%5D%5Bdata%5D=0&columns%5B0%5D%5Bname%5D=&columns%5B0%5D%5Bsearchable%5D=true&columns%5B0%5D%5Borderable%5D=true&columns%5B0%5D%5Bsearch%5D%5Bvalue%5D=&columns%5B0%5D%5Bsearch%5D%5Bregex%5D=false&columns%5B1%5D%5Bdata%5D=1&columns%5B1%5D%5Bname%5D=&columns%5B1%5D%5Bsearchable%5D=true&columns%5B1%5D%5Borderable%5D=true&columns%5B1%5D%5Bsearch%5D%5Bvalue%5D=&columns%5B1%5D%5Bsearch%5D%5Bregex%5D=false&columns%5B2%5D%5Bdata%5D=2&columns%5B2%5D%5Bname%5D=&columns%5B2%5D%5Bsearchable%5D=true&columns%5B2%5D%5Borderable%5D=true&columns%5B2%5D%5Bsearch%5D%5Bvalue%5D=&columns%5B2%5D%5Bsearch%5D%5Bregex%5D=false&order%5B0%5D%5Bcolumn%5D=0&order%5B0%5D%5Bdir%5D=asc&start=0&length=10&search%5Bvalue%5D=&search%5Bregex%5D=false&_=1619613946473

    Si la pasás a través de urldecode queda un poquito más entendible:

    http://localhost:8000/get_data.php?draw=1&columns[0][data]=0&columns[0][name]=&columns[0][searchable]=true&columns[0][orderable]=true&columns[0][search][value]=&columns[0][search][regex]=false&columns[1][data]=1&columns[1][name]=&columns[1][searchable]=true&columns[1][orderable]=true&columns[1][search][value]=&columns[1][search][regex]=false&columns[2][data]=2&columns[2][name]=&columns[2][searchable]=true&columns[2][orderable]=true&columns[2][search][value]=&columns[2][search][regex]=false&order[0][column]=0&order[0][dir]=asc&start=0&length=10&search[value]=&search[regex]=false&_=1619613946473

    O, todavía mejor si lo ves desde la consola del navegador:

    De modo que si del lado de tu PHP hacés un var_dump($_GET) te vas a encontrar con:

    array (size=7)
      'draw' => string '1' (length=1)
      'columns' => 
        array (size=3)
          0 => 
            array (size=5)
              'data' => string '0' (length=1)
              'name' => string '' (length=0)
              'searchable' => string 'true' (length=4)
              'orderable' => string 'true' (length=4)
              'search' => 
                array (size=2)
                  ...
          1 => 
            array (size=5)
              'data' => string '1' (length=1)
              'name' => string '' (length=0)
              'searchable' => string 'true' (length=4)
              'orderable' => string 'true' (length=4)
              'search' => 
                array (size=2)
                  ...
          2 => 
            array (size=5)
              'data' => string '2' (length=1)
              'name' => string '' (length=0)
              'searchable' => string 'true' (length=4)
              'orderable' => string 'true' (length=4)
              'search' => 
                array (size=2)
                  ...
      'order' => 
        array (size=1)
          0 => 
            array (size=2)
              'column' => string '0' (length=1)
              'dir' => string 'asc' (length=3)
      'start' => string '0' (length=1)
      'length' => string '10' (length=2)
      'search' => 
        array (size=2)
          'value' => string '' (length=0)
          'regex' => string 'false' (length=5)
      '_' => string '1619615674442' (length=13)

    Y de ahí… bueno, habrá que hacer la consulta que corresponda, por ejemplo:

    <?php
    
    try {
        $conn = new PDO('sqlite:dt.sq3');
    } catch (PDOException $exception) {
        die($exception->getMessage());
    }
    
    $orderBy = " ORDER BY ";
    foreach ($_GET['order'] as $order) {
        $orderBy .= $order['column'] + 1 . " {$order['dir']}, ";
    }
    
    $orderBy = substr($orderBy, 0, -2);
    $where = '';
    
    $columns = $_GET['columns'];
    $fields = ['id', 'name', 'price'];
    $where = '';
    
    foreach ($columns as $k => $column) {
        if ($search = $column['search']['value']) {
            $where .= $fields[$k].' = '.$search.' AND ';
        }
    }
    
    $where = substr($where, 0, -5);
    $length = $_GET['length'];
    $start = $_GET['start'];
    
    $sql = "SELECT * FROM products ".($where ? "WHERE $where " : '')."$orderBy LIMIT $length OFFSET $start";

    (Me tomé una pequeña licencia… asumí que el search no será una expresión regular… te queda como ejercicio :))

    Cómo debe responder el BackEnd al FrontEnd en DataTables Server-Side

    Por otro lado, es importante que tu php sepa qué es lo que DataTables está esperando como respuesta, de otro modo la comunicación no podrá realizarse exitosamente.

    Lo principal: DataTables espera un resultado JSON que debe contener:

    data: El arreglo de los registros que se levantaron de la base

    recordsTotal: Número total de registros que existen en la base (Sin aplicar filtros)

    recordsFiltered: Número de registros que devuelve la consulta (Una vez aplicados los filtros).

    En definitiva, el código php se completa con:

    $countSql = "SELECT count(id) as Total FROM products";
    $countSt = $conn
        ->query($countSql);
    
    $total = $countSt->fetch()['Total'];
    
    $sql = "SELECT * FROM products ".($where ?? "WHERE $where ")."$orderBy LIMIT $length OFFSET $start";
    $st = $conn
        ->query($sql);
    
    if ($st) {
        $rs = $st->fetchAll(PDO::FETCH_FUNC, fn($id, $name, $price) => [$id, $name, $price] );
    
        echo json_encode([
            'data' => $rs,
            'recordsTotal' => $total,
            'recordsFiltered' => count($rs),
        ]);
    } else {
        var_dump($conn->errorInfo());
        die;
    }

    El código completo lo podés descargar de aquí.

    DataTables ServerSide FTW!

    En este artículo aprendiste las razones por las que tu página con DataTables tarda mucho en cargar y cómo podés acelerar los tiempos de carga.

    ¿Siempre te conviene usar ServerSide? No

    Si la cantidad de registros es pequeña (O más bien diría si no hay retrasos visibles) no te conviene hacer una implementación con esta complejidad, pero en cambio, si los DataTables se están volviendo un cuello de botella SeverSide puede ser una buena solución.

    Ahora que lo sabés, andá a acelerar esa página que los usuarios están esperando!

  • Construyendo una tabla dinámica con PHP, MySQL, DataTables y Ajax

    Construyendo una tabla dinámica con PHP, MySQL, DataTables y Ajax

    ¿Cuántas veces te enfrentaste a la necesidad de mostrar información resumida en forma de tablas?

    O, puesto de otro modo: ¿qué aplicación no requiere del uso de tablas?

    Por supuesto que se puede usar HTML puro y quedará algo más o menos aceptable… pero con las herramientas (¡y los usuarios!) que tenemos hoy en día no podemos darnos el lujo de presentar una experiencia de usuario aceptable.

    Debemos dar lo mejor que podamos.

    Y lo mejor hoy en día implica interactividad.

    Los usuarios quieren poder buscar, ordenar, paginar… prácticamente tener un Excel directo en su aplicación.

    Ya te estarás imaginando el delirio de javascript que tendrías que ponerte a escribir para hacer todo esto desde cero, ¿no? De sólo pensarlo me dan ganas de retomar el curso de peluquería que dejé a la mitad 🙂

    Afortunadamente no es necesario re-inventar la rueda.

    Como podrás imaginarte, existen ya varios componentes que pueden adquirirse gratis (o a muy bajo costo) y que te ahorrarán muchísimas horas de trabajo y dolores de cabeza.

    DataTables es un plugin de jQuery que resuelve muy bien esta necesidad, desafortunadamente la documentación es algo compleja de seguir…

    Es por eso que me propuse escribir este artículo donde te presento un ejemplo completo de implementación de una tabla dinámica que utiliza DataTables combinado con un script php que actúa a modo de WebService REST para levantar los datos de una base.

    Cómo se ve un DataTable

    Empecemos por lo más importante: ¿sirve un DataTable para resolver tu necesidad?

    Una imagen vale más que mil palabras, así que te muestro el ejemplo de una tabla en la que se visualiza el inventario de un comercio: una tabla en la que se verán los datos de cada uno de los productos (Id, Nombre, Precio):

    No está mal, ¿no?

    Repasemos lo que tenemos:

    • ¿Paginación? Lo tenemos!
    • ¿Búsqueda? Lo tenemos!
    • ¿Ordenamiento? Lo tenemos!

    ¿Qué más se le puede pedir a la vida? 🙂

    Vamos ahora a ver qué hay detrás de la cortina.

    El FrontEnd

    El frontend consta de un archivo HTML que se ve así:

    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <link rel="stylesheet" href="//cdn.datatables.net/1.10.24/css/jquery.dataTables.min.css">
        <title>DataTables example</title>
    </head>
    <body>
    <h1>Behold... the power of DataTables!</h1>
    <table id="theTable" class="display" style="width: 100%">
        <thead>
            <tr>
                <th>id</th>
                <th>name</th>
                <th>price</th>
            </tr>
        </thead>
        <tfoot>
            <tr>
                <th>id</th>
                <th>name</th>
                <th>price</th>
            </tr>
        </tfoot>
    </table>
    </body>
    </html>
    <script src="https://code.jquery.com/jquery-3.6.0.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>
    <script src="https://cdn.datatables.net/1.10.24/js/jquery.dataTables.min.js"></script>
    <script type="application/javascript">
        $(document).ready( function () {
            $('#theTable').DataTable({
                ajax: '/get_data.php',
            });
        } );
    </script>

    Analicémoslo un poco:

    <link rel="stylesheet" href="//cdn.datatables.net/1.10.24/css/jquery.dataTables.min.css">

    Esta parte incluye la hoja que aporta los estilos que necesita DataTables.

    La parte principal es la tabla:

    <table id="theTable" class="display" style="width: 100%">
        <thead>
            <tr>
                <th>id</th>
                <th>name</th>
                <th>price</th>
            </tr>
        </thead>
        <tfoot>
            <tr>
                <th>id</th>
                <th>name</th>
                <th>price</th>
            </tr>
        </tfoot>
    </table>

    Es importante que la tabla esté bien formada (Que tenga su sección thead, el tfoot es opcional).

    No hace falta que le pongas un tbody, lo hace todo el plugin.

    Luego tenemos el código JavaScript:

    <script type="application/javascript">
        $(document).ready( function () {
            $('#theTable').DataTable({
                ajax: '/get_data.php'
            });
        } );
    </script>

    Es muy sencillito, se trata de invocar una función cuando el documento está cargado por completo.

    Usando jQuery eso se logra con:

    $(document).ready();

    Y lo que ves dentro es el callback que se invocará cuando este evento ocurra.

    $('#theTable')

    Es la forma de seleccionar el objeto cuyo id es theTable.

    En este caso se trata, obviamente, de la tabla que definimos en el HTML.

    Y al invocar el método DataTable estamos transformando esa simple tabla en un objeto mucho más complejo.

    Por último, este método recibe un parámetro: un objeto que contiene la configuración de la nueva tabla.

    En nuestro caso se ve así:

    {
       ajax: '/get_data.php'
    }

    Lo cual significa que le estamos pasando una configuración con una única propiedad: ajax y su valor (/get_data.php) es la URL de donde se obtendrán, mediante una llamada ajax, los valores con los que se rellenará la tabla.

    El BackEnd

    Ahora que viste cómo funciona el lado cliente, demos una recorrida por la parte del php:

    <?php
    
    try {
        $conn = new PDO('mysql:host=localhost;dbname=ecommerce','root','root');
    } catch (PDOException $exception) {
        die($exception->getMessage());
    }
    
    $sql = "SELECT * FROM products";
    $st = $conn
        ->query($sql);
    
    if ($st) {
        $rs = $st->fetchAll(PDO::FETCH_FUNC, fn($id, $name, $price) => [$id, $name, $price] );
    
        echo json_encode([
            'data' => $rs,
        ]);
    } else {
        var_dump($conn->errorInfo());
        die;
    }

    En este ejemplo estoy usando PDO aunque bien podría usar MySQLi ya que se trata de una base MySQL.

    La primera parte:

    try {
        $conn = new PDO('mysql:host=localhost;dbname=ecommerce','root','root');
    } catch (PDOException $exception) {
        die($exception->getMessage());
    }

    Intenta realizar una conexión y, en caso de fallar emite un mensaje por pantalla para ayudar con el debugging.

    Con este código:

    $sql = "SELECT * FROM products";
    $st = $conn
        ->query($sql);

    Se intenta hacer la consulta de selección de los datos de los productos de la base.

    Si la consulta fue exitosa buscamos el resultado para enviarlo al FrontEnd respondiendo a la llamada Ajax:

    $rs = $st->fetchAll(PDO::FETCH_FUNC, fn($id, $name, $price) => [$id, $name, $price] );
    
    echo json_encode([
        'data' => $rs,
    ]);

    La primera línea se ve algo extraña, ¿no?

    Paso a explicar 🙂

    Un detalle que puede ahorrarte varios dolores de cabeza es comprender cómo DataTables espera recibir los resultados del webservice que le brinda los datos.

    Se trata de un objeto json con este formato:

    {
       data:[
         [ f1, f2, f3 ],
         [ f1, f2, f3 ],
         [ f1, f2, f3 ],
       ]
    }

    Donde f* corresponde a un campo del registro encontrado.

    En este caso, para que todo salga bien, el php debe responder con algo como:

    {
       data:[
         [ 1, 'Chair', 200.0 ],
         [ 2, 'Table', 500.0 ],
         [ 3, 'Shoes', 450.0 ],
       ]
    }

    Y hay que aclarar: DataTables es muy estricto con esto!

    Es por eso que usé PDO::FETCH_FUNC (Bueno, por eso y porque soy un fanático de la programación funcional) para generar, en base a los registros obtenidos, el arreglo correspondiente.

    Y luego es simple: con la función json_encode se genera el string que se requiere.

    En conclusión

    Espero que este pequeño ejemplo te haya ayudado a comprender lo que podés lograr con este plugin y un poco de php.

    La próxima vez que tengas que mostrar resultados en forma de tabla probalo y comentá cómo te fué 🙂

    Ah! Y si querés descargar el código completo podés hacerlo desde GitHub.

  • Implementación de roles basada en PHP y MySQL

    Implementación de roles basada en PHP y MySQL

    Una pregunta que veo a menudo:

    Estoy haciendo un inicio de sesión en php. el usuario solo debe ingresar su nombre y sera re direccionado dependiendo del rol que tenga.


    Estoy realizando un sistema en php y mysql quisiera saber cómo trabajar con múltiples sesiones como por ejemplo que tenga una cuenta de administrador y pueda trabajar con toda las páginas y tener cuenta de usuario que algunos vean cierta cantidad de páginas y realizar pocas funciones en el sistema


    ¿Cómo debo crear una sesión para el administrador? ¿En qué lugar de la aplicación se tiene que reflejar?
    El usuario normal no debe ver esa parte que le corresponde al Administrador.


    quiero ingresar roles de usuario en mi código pero no se como hacerlo.


    Voy a crear una aplicación web para unas personas que llevan el control de unas muestras geológicas, básicamente intervienen dos grupos de usuarios el gerente del área y sus especialistas, por lo tanto no pueden tener los mismos privilegios.

    La necesidad de tener diferentes secciones del sitio disponibles para diferentes roles o niveles de usuario es sumamente común.

    En este artículo mostraré cómo podría implementarse un esquema como este usando PHP y MySQL.

    Aclaración: en mi caso personal, para resolver este problema utilizaría Symfony, pero en este post utilizaré PHP puro para hacer la solución más generalmente aplicable

    Cómo guardar los roles en la base de datos

    Mi sugerencia siempre es comenzar por definir el modelo de datos.

    La base de datos constituye los cimientos de cualquier aplicación web y si éstos no son sólidos, por más magia que tenga nuestro PHP, CSS, JavaScript y demás va a ser difícil terminar con algo mucho mejor que esto:

    Así que… mejor pisar sobre seguro.

    La primera pregunta que debés responder es: ¿cada usuario tendrá un único rol?

    Si la respuesta es sí el esquema de la base de datos es bastante simple, sólo se trata de agregar un campo a la tabla users: role.

    Aquí se abre una segunda bifurcación: ¿de qué tipo debe ser el campo role?

    La idea más natural es crearlo como string (o tal vez enumerado).

    Mi sugerencia, sin embargo, es hacer algo un poco más complicado:

    • Crear una nueva tabla roles con dos campos:
      • id
      • nombre
    • Agregar un campo role_id (en lugar de role) a la tabla users como una clave foránea a la tabla roles

    En definitiva, este sería el código SQL de la creación de las tablas:

    CREATE TABLE `roles` (
      `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
      `name` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL,
      PRIMARY KEY (`id`),
      UNIQUE KEY `name` (`name`)
    );
    
    CREATE TABLE `users` (
      `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
      `email` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL,
      `password` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL,
      `role_id` int(10) unsigned DEFAULT NULL,
      PRIMARY KEY (`id`),
      UNIQUE KEY `email` (`email`),
      KEY `fk_roles` (`role_id`),
      CONSTRAINT `fk_roles` FOREIGN KEY (`role_id`) REFERENCES `roles` (`id`)
    );

    De esta forma el sistema está preparado para incorporar nuevos roles sin necesidad de alterar la estructura de la base (Y por lo tanto, posibilitar hacerlo desde el front-end sin intervención del desarrollador).

    En el caso de que los usuarios puedan tener más de un rol simultáneamente la estructura sería un poco más compleja: deberá incluir una tabla intermedia para implementar la relación N-a-M:

    -- Queda igual que en el caso anterior
    CREATE TABLE `roles` (
      `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
      `name` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL,
      PRIMARY KEY (`id`),
      UNIQUE KEY `name` (`name`)
    );
    
    -- Se elimina el campo role_id
    CREATE TABLE `users` (
      `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
      `email` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL,
      `password` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL,
      PRIMARY KEY (`id`),
      UNIQUE KEY `email` (`email`)
    );
    
    CREATE TABLE `users_roles` (
      `user_id` int(10) unsigned NOT NULL,
      `role_id` int(10) unsigned NOT NULL,
      PRIMARY KEY (`user_id`,`role_id`),
      KEY `fk_roles_2` (`role_id`),
      CONSTRAINT `fk_roles_2` FOREIGN KEY (`role_id`) REFERENCES `roles` (`id`),
      CONSTRAINT `fk_users` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`)
    );

    Para no complicar mucho el ejemplo voy a tomar el caso de un rol por usuario aunque no cambiará mucho si se trata del otro (Tal vez lo escriba en un próximo artículo).

    Bien, la base de datos está, ahora es momento de ver cómo hacer el código PHP.

    Cómo redireccionar al usuario según su rol

    El siguiente desafío consiste en, una vez haya ingresado el usuario, redirigirlo a la sección del sitio que le corresponda según su rol.

    Comencemos por un simple formulario de login:

    <html>
    <body>
      <form action="processLogin.php" method="post">
         <input type="text" name="username"/>
         <input type="password" name="password"/>
         <input type="submit" value="Ingresar"/>
      </form>
    </body>
    </html>

    Y el archivo del backend

    <?php
    
    $username = $_POST['username'];
    $password = $_POST['password'];
    
    $sql = "SELECT u.id, r.name AS role, password FROM users u INNER JOIN roles r ON r.id = u.role_id WHERE username = '$username';";
    
    // Conectar a la base de datos
    // ejecutar la consulta
    // $result contiene el resultado de la consulta
    
    if (password_verify($result['password'], password_hash($password, PASSWORD_BCRYPT))) {
       $startingPage = [
          'admin' => 'admin_home.php',
          'user' => 'user_home.php',
       ];
    
       $nextPage = array_key_exists($result['role'], $startingPage) ? $startinPage['role'] : 'user_home.php';
       if (array_key_exists($result['role'], $startingPage)) {
          $nextPage = $startinPage[$result['role']];
       } else {
          $nextPage = $startinPage['user'];
          error_log('There is no starting page for role '.$result['role']);
       }
       session_start();
       $_SESSION['user_id'] = $result['id'];
       $_SESSION['role'] = $result['role'];
       header('Location: '.$nextPage);
    } else {
       header('Location: login.html');
    }

    Aquí estoy usando las funciones password_verify y password_hash para hacer un login seguro.

    Y con esto hemos logrado dirigir a cada persona a la sección que le corresponde…

    Lo que nos falta es dejar afuera a los que quieran ir a mirar donde no les corresponde 😉

    Cómo restringir el acceso a una sección del sitio según el rol del usuario

    Una forma simple de restringir el acceso para alguien que no se logeó la podés leer acá.

    La idea de esta sección es ampliar un poco sobre esa misma línea y permitir el acceso sólo para aquellos usuarios cuyo rol está habilitado:

     <?php
    
    // admin_home.php
    
    session_start();
    
    if (!array_key_exists('user_id', $_SESSION)) {
       header('Location: login.html');
       die;
    }
    
    $allowedRoles = ['admin'];
    
    if (!array_key_exists('role', $_SESSION) || !in_array($_SESSION['role'], $allowdRoles)) {
       header('Location: login.html');
       die;
    }
    ?>
    <h1>Bienvenido amind!</h1>

    Habrás observado que el protagonista de esta pequeña novela es el array $_SESSION.

    Si todavía no lo tienes super claro, te sugiero continuar leyendo por aquí.

  • Una aplicación web a prueba de falta de conectividad

    Una aplicación web a prueba de falta de conectividad

    A raíz de un artículo que escribí para mi newsletter me llegó esta pregunta:

    Y como no puedo negarme a un pedido semejante, aquí estoy 🙂

    Este va a ser un post algo atípico ya que el protagonista no será, como acostumbro, PHP si no JavaScript, por una razón sencilla: la acción más importante sucederá del lado del cliente y no del servidor.

    Voy a hacer una aplicación del estilo prueba de concepto, es decir, van a quedar unos cuantos «cabos sueltos» pero la idea es que comprendas el principio detrás de esto.

    El escenario que planteo es el siguiente:

    Existe una base de datos en el servidor y muchos clientes interactuando con ella a la vez.

    Un ejemplo real de esto es una aplicación tipo Google Docs o tal vez un juego online multi-jugador (Acá sólo me queda imaginar porque no tengo mucha experiencia en este área :p).

    Pues bien, un modo de encarar este problema pensando en conexiones poco confiables es utilizar un esquema tipo event sourcing donde, en lugar de almacenar el estado actual de los objetos guardamos la secuencia de eventos que conducen a que las cosas sean como las vemos.

    De este modo, cada cliente enviará al servidor sus novedades, el servidor las recibirá y las re-distribuirá a los demás y dejará en manos de cada cliente refrescar la vista.

    Un problema que deberemos resolver es el de la sincronización: el orden en que sucedieron los eventos es importante y los relojes de cada uno de los clientes pueden estar des-sincronizados con lo cual no serán suficientemente confiables.

    La solución que yo elegí es tomar la hora del servidor como base y que cada cliente marque los tiempos como la cantidad de segundos que han transcurrido desde la primera marca enviada por el servidor.

    Para no complicar más el ejemplo dejaré de lado cuestiones como la resolución de conflictos, aunque es claro que en un escenario real habría que tomarlos en cuenta también.

    Otra pequeña licencia que me tomaré será asumir que la primera interacción con el servidor es siempre exitosa.

    Para simular la falta de conectividad usaré un botón que permita detener/comenzar la sincronización automática.

    En el caso real utilizaría simplemente una verificación de Navigator.online en forma periódica y, al detectar que hay conectividad aprovechar para enviar los eventos que corresponden.

    Ajax

    La base de este script es la utilización de peticiones asincrónicas (más conocidas como Ajax).

    Mediante esta tecnología es posible realizar aplicaciones de tipo single-page (de una sola página).

    Se carga una vez un HTML que hace de marco y, a través de JavaScript se manejan las interacciones con el servidor.

    LocalStorage

    El otro concepto importante sobre el que se basa esta pequeña aplicación es el de almacenamiento local.

    Se trata de una característica de los navegadores web modernos que permite almacenar información en formato clave-valor.

    Esto me permitirá persistir información del lado del cliente aún en el caso de que la sesión se cierre abruptamente (Por ejemplo por un corte de luz).

    Y ahora sí, vamos a ver algo de código 🙂

    El código

    El backend

    Del lado del servidor tendremos un simple archivo php:

    <?php
    
    const FILE_NAME = 'events.json';
    $lastSync = $_GET['lastSync'];
    $clientId = $_GET['clientId'];
    
    $events = is_readable(FILE_NAME) ? json_decode(file_get_contents(FILE_NAME), true) : [];
    
    $newEvents = array_filter($events, function (array $event) use ($clientId, $lastSync) {
    
        return $event['clientId'] != $clientId && $event['timestamp'] > $lastSync;
    });
    
    $receivedText = file_get_contents('php://input');
    
    error_log('Got ' . $receivedText);
    $receivedEvents = json_decode($receivedText,true);
    
    foreach ($receivedEvents as $receivedEvent) {
        $receivedEvent['clientId'] = $clientId;
        $events[] = $receivedEvent;
    }
    
    usort($events, function ($e1, $e2) {
        if ($e1['timestamp'] == $e2['timestamp']) {
    
            return 0;
        } elseif ($e1['timestamp'] > $e2['timestamp']) {
    
            return 1;
        } else {
    
            return -1;
        }
    });
    
    file_put_contents(FILE_NAME, json_encode($events));
    
    error_log('Sending new events: '.json_encode($newEvents));
    
    echo json_encode([
        'newEvents' => $newEvents,
        'clientId' => $clientId ?: uniqid(),
        'timestamp' => time(),
    ]);

    Este pequeño servidor se encargará de enviar a cada cliente los eventos que sucedieron luego de una cierta marca de tiempo (Que se supone corresponde con el último evento que el cliente recibió del servidor) en algún otro cliente.

    A su vez, tomará los nuevos eventos enviados por el cliente y los incorporará a la base de datos centralizada.

    Por último, para el caso especial en que el cliente no haya informado su id se asumirá que se trata de la primera interacción, con lo cual, le será asignado uno.

    El frontend

    Por el lado del frontend tendremos dos archivos, un HTML:

    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <title>Records</title>
    </head>
    <body>
    <h1 style="display: none;" id="clientInfo">Soy el cliente <span id="clientId"></span></h1>
    <table id="record_table" border="1">
        <thead>
        <tr>
            <th>Id</th>
            <th>Tiempo</th>
            <th>Contenido</th>
            <th></th>
        </tr>
        </thead>
        <tbody>
        </tbody>
    </table>
    <p><label for="newContent">Agregar:</label> <input id="newContent"/></p>
    <p>Sincronización: <span id="syncEnabled"></span>
        <button id="toogleSync"/>
    </p>
    </body>
    </html>
    <script type="text/javascript" src="sync.js"></script>

    Y un JavaScript:

    let clientId = localStorage.getItem('clientId') || '';
    let events = JSON.parse(localStorage.getItem('events')) || [];
    let nonSynced = JSON.parse(localStorage.getItem('nonSynced')) || [];
    let syncEnabled = false;
    let initialTime = events.length ? events[events.length - 1].timestamp : 0;
    let lastSync = 0;
    let elapsed = 0;
    let nextLocalId = 1;
    
    document.getElementById('clientId').innerText = clientId;
    document.getElementById('syncEnabled').innerText = 'deshabilitada';
    document.getElementById('toogleSync').innerText = 'Habilitar';
    
    refreshTable();
    
    window.setInterval(function () {
        if (navigator.onLine && clientId && syncEnabled) {
            sync();
        }
    }, 3000);
    
    window.setInterval(function () {
        elapsed += 1;
    }, 1000);
    
    if ('' == clientId) {
        sync();
    }
    
    document.getElementById('toogleSync').addEventListener('click', toggleSync);
    
    document.getElementById('newContent').addEventListener('keyup', function (event) {
        if ("Enter" == event.code) {
            addRecord(this.value, initialTime + elapsed);
            this.value = '';
        }
    });
    
    function toggleSync() {
        syncEnabled = !syncEnabled;
    
        if (syncEnabled) {
            document.getElementById('syncEnabled').innerText = 'habilitada';
            document.getElementById('toogleSync').innerText = 'Deshabilitar';
        } else {
            document.getElementById('syncEnabled').innerText = 'deshabilitada';
            document.getElementById('toogleSync').innerText = 'Habilitar';
        }
    }
    
    function removeRecord(id) {
        let event = events.find(event => event.id == id);
    
        if (nonSynced.find(event => event.id == id)) {
            removeAddEvent(event.id);
        } else {
            addDeleteEvent(event.id);
        }
    
        events = events.filter(event => event.id != id);
    
        refreshTable();
    }
    
    function addDeleteEvent(id) {
        let newEvent = {
            action: 'D',
            id: id,
            timestamp: initialTime + elapsed
        };
    
        events.push(newEvent);
        nonSynced.push(newEvent);
    }
    
    function removeAddEvent(id) {
        events = events.filter(event => event.id != id);
        nonSynced = nonSynced.filter(event => event.id != id);
    
        localStorage.setItem('events',JSON.stringify(events));
        localStorage.setItem('nonSynced',JSON.stringify(nonSynced));
    }
    
    function addRecord(value, timestamp, id = null) {
        let newEvent = {
            'timestamp': timestamp,
            'action': 'A',
            'contents': value,
            'id': id ? id : clientId + '-' + nextLocalId++,
        };
    
        if (!id) {
            nonSynced.push(newEvent);
            localStorage.setItem('nonSynced', JSON.stringify(nonSynced));
        }
    
        events.push(newEvent);
        localStorage.setItem('events',JSON.stringify(events));
    
        events.sort(function (event1, event2) {
            if (event1.timestamp == event2.timestamp) {
    
                return 0;
            } else if (event1.timestamp > event2.timestamp) {
    
                return 1;
            } else {
    
                return -1;
            }
        });
    
        refreshTable();
    }
    
    function refreshTable() {
        let table = document.getElementById('record_table');
        let oldBody = table.tBodies[0];
        let newBody = document.createElement('tbody');
    
        let addEvents = events.filter(event => 'A' === event.action);
        for (let i in addEvents) {
            if (events.find(event => 'D' === event.action && addEvents[i].id == event.id)) {
                continue;
            }
    
            let row = newBody.insertRow();
    
            let cell = document.createElement('td');
            cell.innerText = addEvents[i].id;
            row.append(cell);
    
            cell = document.createElement('td');
            cell.innerText = addEvents[i].timestamp;
            row.append(cell);
    
            cell = document.createElement('td');
            cell.innerText = addEvents[i].contents;
            row.append(cell);
    
            cell = document.createElement('td');
            cell.innerHTML = '<button onclick="removeRecord(\'' + addEvents[i].id + '\')">Eliminar</button>';
            row.append(cell);
        }
    
        table.replaceChild(newBody, oldBody);
    }
    
    function addRemoteEvents(newEvents) {
        for (let i in newEvents) {
            events.push(newEvents[i]);
            lastSync = newEvents[i].timestamp;
        }
    
        events.sort(function(e1, e2) {
            if (e1.timestamp == e2.timestamp) {
    
                return 0;
            } else if (e1.timestamp > e2.timestamp) {
    
                return 1;
            } else {
    
                return -1;
            }
        });
        localStorage.setItem('events',JSON.stringify(events));
    }
    
    function sync() {
        var xhttp = new XMLHttpRequest();
    
        document.getElementById('newContent').disabled = true;
        xhttp.onreadystatechange = function () {
            if (this.readyState == 4 && this.status == 200) {
                document.getElementById('newContent').disabled = false;
                let response = JSON.parse(this.responseText);
                if (!clientId) {
                    clientId = response.clientId;
                    localStorage.setItem('clientId', clientId);
                    document.getElementById('clientId').innerText = clientId;
                    initialTime = response.timestamp;
                    document.getElementById('clientInfo').style = 'display: block;';
                }
                addRemoteEvents(response.newEvents);
                refreshTable();
                nonSynced = [];
                localStorage.setItem('nonSynced', JSON.stringify(nonSynced));
            }
        };
        xhttp.open("POST", "sync.php?lastSync=" + lastSync + "&clientId=" + clientId, true);
        xhttp.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
        xhttp.send(JSON.stringify(nonSynced));
    }

    Este último (La parte más interesante) funciona de la siguiente manera:

    Lo primero que se intenta es re-establecer el estado recurriendo a lo almacenado en LocalStorage.

    Para atacar el problema de la sincronización tomamos un tiempo inicial provisto por el servidor y, cada vez que se produce un evento le sumamos la cantidad de segundos transcurridos en el cliente, de modo de que todos los eventos tengan una misma referencia.

    Este algoritmo está lejos de ser perfecto, pero lo uso para ilustrar precisamente el desafío que supone tratar con eventos en forma distribuida.

    Constantemente mantengo en memoria una lista de eventos y otra de eventos aún no sincronizados.

    De esta forma, los eventos se van encolando mientras la sincronización no está activa y, apenas se detecta conectividad, se envían todas las novedades y se procesan las que los demás clientes han producido.

    Esas mismas listas se almacenan en el LocalStorage para prevenir una desincronización por, por ejemplo, una súbita pérdida de energía.

    Conclusión

    Como puedes ver, darle este tipo de robustez a una aplicación web no es precisamente tarea sencilla

    Honestamente, dudo de que haya muchos escenarios reales que lo justifiquen.

    Seguramente existen frameworks JavaScript que resuelven estos problemas de una mejor forma, sólo quise mostrarlo en la versión cruda para que queden claras las dificultades por subsanar.

  • Un dashboard en tiempo real basado en PHP y Bootstrap

    Un dashboard en tiempo real basado en PHP y Bootstrap

    Algo que está muy de moda por estos días es la creación de tableros de comandos (Dashboards) que se mantengan actualizados en tiempo real.

    Si bien la definición de tiempo real es algo vaga (Formalmente se trata de sistemas en los cuales el tiempo de respuesta es crítico), hay una suerte de conocimiento en común respecto de lo que quiere decir: que los cambios se vean en forma inmediata (o casi).

    En lo que hace a aplicaciones web, de lo que se trata es de permitir al visitante recibir novedades sin tener que recargar la página.

    Para lograr este efecto se necesitan dos partes coordinadas:

    1. Una aplicación front-end con la que el usuario interactuará en forma directa
    2. Un servidor que mantenga la información actualizada en todo momento

    Un frontend para el Dashboard

    Del lado del frontend, lo que necesitaremos serán dos cosas:

    1. Una capa de presentación (HTML + CSS)
    2. Una capa de interacción con el backend (JavaScript)

    Presentación del dashboard

    Del lado de la presentación, dado que se trata de un dashboard, lo que querremos hacer es mostrar información resumida, en algún formato fácil de interpretar por una persona (Usualmente se tratará de gráficos pero podemos usar cualquier forma que nos parezca adecuada).

    Una librería que resulta muy útil para realizar este tipo de tareas es Bootstrap (Que, de paso, nos da una visualización estándar).

    En este ejemplo haré un dashboard muy sencillo, sólo a modo de ilustración, pero con los mismos principios puedes hacer otros mucho más vistosos.

    Veamos el HTML:

    <!doctype html>
    <html lang="en">
    <head>
        <!-- Required meta tags -->
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    
        <!-- Bootstrap CSS -->
        <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css"
              integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
    
        <title>Hello, world!</title>
    </head>
    <body>
    <h1>Hola, este es mi dashboard</h1>
    <div class="container">
        <div class="row">
            <div class="col-sm">
                <div class="card">
                    <div class="card-body">
                        Usuarios conectados en este momento: <strong><span id="connected_users">0</span></strong>
                    </div>
                </div>
            </div>
            <div class="col-sm">
                <div class="card">
                    <div class="card-body">
                        Facturación del día: <strong>$ <span id="daily_revenue">0.00</span></strong>
                    </div>
                </div>
            </div>
        </div>
    </div>
    </body>
    </html>

    Aquí lo que estoy haciendo es crear el esqueleto de mi dashboard.

    Para el ejemplo tomé dos métricas que pueden resultar interesantes:

    • Cantidad de usuarios conectados
    • Facturación del día

    La pantalla inicial se verá así:


    Si te fijas, el tag link (en la sección head) incluye el CSS que necesito para usar Bootstrap.

    Interacción con el backend del dashboard

    El siguiente paso es agregar la interacción con el backend (¡Que aún no está hecho! Cierto… no nos adelantemos, al final del artículo tendrás todo lo que buscas :).

    Lo primero es incluir la librería jQuery:

    <script
            src="https://code.jquery.com/jquery-3.4.1.min.js"
            integrity="sha256-CSXorXvZcTkaix6Yvo6HppcZGetbYMGWSFlBw8HfCJo="
            crossorigin="anonymous"></script>

    Podría escribir todo el JavaScript necesario sin utilizarla, es cierto, pero sería muchísimo más trabajo del necesario y, honestamente, prefiero evitármelo :p

    Algo que puedes notar es que estoy incluyendo la librería desde otro servidor (code.jquery.com, un CDN).

    Y ahora me queda incluir algo de código mío (el que efectivamente interactuará con mi servidor):

    <script type="text/javascript">
        window.setInterval(function () {
            updateStats();
        }, 2000);
    
        function updateStats() {
            updateConnectedUsers();
            updateDailyRevenue();
        }
    
        function updateConnectedUsers() {
            jQuery.get(
                'get_connected_users.php',
                function (data) {
                    $('#connected_users').text(data);
                }
            );
        }
    
        function updateDailyRevenue() {
            jQuery.get(
                'get_daily_revenue.php',
                function (data) {
                    $('#daily_revenue').text(data);
                }
            );
        }
    </script>

    Aquí lo que estoy haciendo es generar un mecanismo de polling de modo que el cliente esté constantemente pidiendo actualizaciones al servidor (y reflejándolas en el HTML para el visitante).

    Muy bien, sólo nos queda ver el backend 🙂

    Un backend para el dashboard

    Del lado del backend sólo debo crear los archivos que puedan responder a las preguntas del frontend (get_connected_users.php y get_daily_revenue.php).

    En mi ejemplo haré algo de trampa, no voy a consultar a una base de datos ni nada, simplemente generaré valores aleatorios, pero claramente, en una aplicación real, deberías usar algún tipo de almacenamiento.

    get_connected_users.php:

    <?php
    
    header('Content-Type: text/javascript');
    echo rand(0, 200);

    get_daily_revenue.php:

    <?php
    
    session_start();
    
    $acummulatedRevenue = $_SESSION['acummulatedRevenue'] ?? 0;
    
    header('Content-Type: text/javascript');
    
    $_SESSION['acummulatedRevenue'] = $acummulatedRevenue + rand( 0, 100000 ) / 100;
    
    echo number_format( $_SESSION['acummulatedRevenue'], 2, ',', '.' );

    Lo importante de ambos scripts es la línea header(‘Content-Type: text/javascript’);.

    Esto es lo que hace que la llamada Ajax entienda que lo que recibe del servidor es, efectivamente, texto jSON

    Dashboards más profesionales

    Con lo que te mostré hasta aquí tienes todo lo necesario para hacer un dashboard básico (y bastante poco estético :p).

    Ciertamente existen plantillas de dashboards mucho más atractivas que puedes usar (o ser creativo y crear las tuyas!), algunas que puedes probar:

    ¡Ya estás list@ para agregar bonitos dashboards a tus aplicaciones! ¡Adelante!