En esta segunda entrega de Creando un Crud con Laminas, vamos a personalizar el diseño de la aplicación para que luzca diferente, a continuación vamos a configurar la base de datos MySQL y vamos a listar tareas ya desde base de datos en nuestra aplicación.
Definiendo un layout
La mayoría de los sitios web tienen un diseño coherente del sitio, este diseño se le llama Layout. Incluye hojas de estilo CSS, archivos JavaScript necesarios, así como la estructura donde será inyectado el contenido. En el archivo de configuración de nuestro módulo /config/module.config.php dentro de la llave/clave template_map del array vamos agregar lo siguiente:
'template_map' => [
'layout/layout' => __DIR__ . '/../view/layout/layout.phtml',
],
Y vamos a crear el archivo layout.phml en view/layout, a continuación voy a tomar un template predefinido de bootstrap 4. En la página de bootstrap, https://getbootstrap.com/docs/4.6/examples/, y elijo el template Dashboard.
Vamos a customizar según nuestras necesidades. Ya tengo el template ajustado así que solo copia y pega el código.
<?= $this->doctype() ?>
<html lang="en">
<head>
<meta charset="utf-8">
<?= $this->headTitle('ToDos App')->setSeparator(' - ')->setAutoEscape(false) ?>
<?= $this->headMeta()
->appendName('viewport', 'width=device-width, initial-scale=1.0')
->appendHttpEquiv('X-UA-Compatible', 'IE=edge')
?>
<!-- Le styles -->
<?= $this->headLink(['rel' => 'shortcut icon', 'type' => 'image/vnd.microsoft.icon', 'href' => $this->basePath() . '/img/favicon.ico'])
->prependStylesheet($this->basePath('css/bootstrap.min.css'))
->prependStylesheet($this->basePath('css/dashboard.css'))
?>
<!-- Scripts -->
<?= $this->headScript() ?>
</head>
<body>
<nav class="navbar navbar-dark sticky-top bg-dark flex-md-nowrap p-0 shadow">
<a class="navbar-brand col-md-3 col-lg-2 mr-0 px-3" href="#">To Dos App</a>
<button class="navbar-toggler position-absolute d-md-none collapsed" type="button" data-toggle="collapse"
data-target="#sidebarMenu" aria-controls="sidebarMenu" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
</nav>
<div class="container-fluid">
<div class="row">
<nav id="sidebarMenu" class="col-md-3 col-lg-2 d-md-block bg-light sidebar collapse">
<div class="sidebar-sticky pt-3">
<ul class="nav flex-column">
<li class="nav-item">
<a class="nav-link active" href="<?=$this->url('todo-app', ['action'=> 'index'])?>">
<span data-feather="home"></span>
Todo List <span class="sr-only">(current)</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">
<span data-feather="file"></span>
New task
</a>
</li>
</ul>
</div>
</nav>
<main role="main" class="col-md-9 ml-sm-auto col-lg-10 px-md-4">
<?= $this->content ?>
</main>
</div>
</div>
<?= $this->inlineScript()
->prependFile($this->basePath('js/bootstrap.min.js'))
->prependFile($this->basePath('js/jquery-3.5.1.min.js')) ?>
</body>
</html>
Vamos a describir qué es lo que sucede en este archivo:
- El helper headTitle(), permite crear y almacenar una etiqueta
<title>
de HTML para su posterior recuperación y salida. Un uso típico es nombrar un sitio web en el título. -
headMeta() usualmente genera una etiqueta
<meta>
de HTML. Se pueden generar meta-etiquetas en cualquier momento. Tipicamente puedes especificar reglas de caché del lado del cliente o palabras clave de SEO. Por ejemplo si deseas especificar keyworks para asociar con tu web. -
headLink() genera una etiqueta
<link>
de HTML, es posible agregar archivos CSS externos. Permite especificar todos los atributos necesarios para un elemento<link>
y también permite especificar la ubicación. Se debe colocar en el layout en la sección<head>
. El orden es muy importante al especificar estilos CSS: es posible que debas asegurarte que los estilos se carguen en un orden específico, para eso se puede usar algunos métodos como los siguientes:- appendStylesheet(): agrega una declaración de estilos al final de la lista.
- offsetSetStylesheet(): especifica un lugar en particular en la lista de estilos.
- prependStylesheet(): agrega un estilo al inicio de la lista.
- setStylesheet(): establece una lista específica de estilos.
-
headScript(): el elemento HTML Script
(<script>)
se utiliza para insertar o hacer referencia a un script ejecutable dentro de un documento HTML. El helper headScript() te permite administrar scripts inline y externos. Al igual que headLink tiene métodos similares para generar y colocar los scripts según el orden deseado. - appendScript(): agrega un script al final de la lista de scripts.
- offsetSetScript(): especifica un lugar donde colocar un script en particular.
- prependScript(): agrega un script al inicio de la lista.
- inlineScript(): es derivado de headScript y cualquier método de este helper está disponible para usar con inlineScript(). Este debe ser usado cuando deseas incluir scripts en línea dentro del body. Colocar los scripts al final del documento es una buena práctica para acelerar la carga de la página.
Por último, para inyectar el contenido de las vistas en el layout, el objeto ViewModel captura el contenido en la variable content, eso significa que podemos hacer lo siguiente en el layout:
<main role="main" class="col-md-9 ml-sm-auto col-lg-10 px-md-4">
<?= $this->content ?>
</main>
Ahora si actualizas tu navegador deberías ver algo como esto:
Estructura de la base de datos y el Modelo
Es hora de empezar a crear la base de datos. Yo voy a crear la base de datos en MySql. Le voy a llamar todos-app. Podemos usar algún gestor de base de datos aunque para esta tarea sencilla vamos a usar el cliente de MySQL, abrimos la terminal y tecleamos lo siguiente:
$ mysql -upeter -p
Enter password:
Donde, con -u especificamos el usuario de la base de datos, en esta caso peter y con -p, vamos a teclear el password de la base de datos. Una vez que estemos conectados al servidor MySQL aparecerá un mensaje de bienvenida y se mostrará el indicador mysql>, en el cual podemos enviar los comandos SQL para su ejecución.
Estando en el promp de mysql (mysql>
), vamos a crear la base de datos con la siguiente sentencia:
create database todo_app;
Dependiendo de la versión que tengas de MySQL va a ser el tipo de codificación de caracteres que se aplique a la base de datos. Para fines prácticos los vamos a dejar como los setea por default MySQL.
A continuación vamos a crear la tabla para almacenar las tareas (task) de nuestra aplicación, esto mediante el siguiente script SQL el cual pegamos en el prompt de MySQL:
CREATE TABLE `task` (
`id` int NOT NULL AUTO_INCREMENT,
`title` varchar(45) NOT NULL,
`description` text,
`creation_date` datetime DEFAULT NULL,
`finish_date` datetime DEFAULT NULL,
`finished` tinyint NOT NULL DEFAULT '1',
PRIMARY KEY (`id`)
) ENGINE=InnoDB;
En la siguiente imagen podemos ver cual es la respuesta después de pegar el script SQL, si todo va bien mysql va a contestar, Query OK, 0 rows affected (0.05 sec):
Este script crea las siguientes columnas en la tabla task:
Vamos a insertar algunos registros a la base de datos para despues leerlos mediante nuestra aplicación:
INSERT INTO todos_app.task (title,description,creation_date,finish_date,finished)
VALUES
('My first task','Remember that the model is the part that deals with the application''s core purpose (the so-called “business rules”) and, in our case, deals with the database.','2021-03-01 14:19:27.0',NULL,0),
('Buy milk',NULL,'2021-03-02 10:24:17.0',NULL,0);
Configurando la base de datos
Para configurar el acceso a base de datos de nuestra aplicación lo vamos hacer en el archivo de configuración global.php, bajo config/autoload, con lo siguiente:
<?php
return [
'db' => [
'driver' => 'Pdo_Mysql',
'dsn' => 'mysql:dbname=todos_app;host=localhost;charset=utf8',
'driver_options' => [
PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES \'UTF8\''
],
],
'service-manager' => [
'factories' => [
'Laminas\Db\Adapter\Adapter' => 'Laminas\Db\Adapter\AdapterServiceFactory'
]
]
];
Aquí estamos definiendo el driver para MySQL definido en la llave del array driver, en dsn, definimos el driver(mysql), el nombre de la base de datos (todos_app), el host (localhost) y el tipo de codificación (utf8) además de algunas opciones del driver. En el archivo local.php podemos almacenar las credenciales de la base de datos, lo hacemos aquí dado que este archivo se mantiene fuera del control de versiones. Creamos el archivo local.php bajo config/autoload/ y dependiendo de las credenciales de tu base de datos debes de cambiar los valores para username y password.
return [
'db' => [
'username' => 'peter',
'password' => 'g00r007',
],
];
El modelo
Esta parte se ocupa de la lógica de negocio de la aplicación. Se puede utilizar para realizar validaciones de datos, procesarlos y almacenarlos. Los datos pueden provenir de diferentes fuentes como:
- Documentos XML o JSON.
- Base de datos de archivo plano.
- Otras fuentes de datos válidas.
Laminas no proporciona un componente para los modelos ya que el modelo es parte de la lógica de tu negocio y depende de ti decidir cómo quieres que funcione. Puedes tener clases modelo que representan una entidad (tabla) de tu aplicación y luego usar objetos para cargar (leer) y guardar entidades en la base de datos. También puedes usar un ORM (Object Relational Mapping) cómo Doctrine.
Para esta aplicación vamos a implementar el patrón de diseño Table Data Gateway para permitir interactuar con una tabla de la base de datos. Hay que ser cuidadosos con este patrón de diseño ya que puede ser muy limitante para sistemas muy grandes.
El proposito de Table Data Gateway es:
Un objeto actúa como puerta de enlace (gateway) a una tabla de la base de datos. Y una instancia maneja todas las filas de la tabla.
Explicación
El patrón Table Data Gateway separa las consultas SQL del código fuente.
Laminas tiene su propia implementación del patrón de diseño Table Gateway
.
Comencemos creando el archivo Task.php bajo TodosApp/src/Model:
<?php
namespace TodosApp\Model;
class Task
{
public $id;
public $title;
public $description;
public $creationDate;
public $finishDate;
public $finished;
public function exchangeArray(array $data)
{
$this->id = !empty($data['id']) ? $data['id'] : null;
$this->title = !empty($data['title']) ? $data['title'] : null;
$this->description = !empty($data['description']) ? $data['description'] : null;
$this->creationDate = !empty($data['creation_date']) ? $data['creation_date'] : null;
$this->finishDate = !empty($data['finish_date']) ? $data['finish_date'] : null;
$this->finished = !empty($data['finished']) ? $data['finished'] : null;
}
}
En el anterior código tenemos cada una de las columnas de la tabla Task de la base de datos convertidas en propiedades de la clase Task. Y el método exchangeArray() que copia los datos proporcionados por el array $data
a las propiedades de nuestra entidad.
Ahora vamos a crear el archivo TaskTable.php
que consume la interfaz Laminas\Db\TableGateway\TableGateway
. Copia el siguiente código bajo TodosApp/src/Model:
<?php
namespace TodosApp\Model;
use Laminas\Db\TableGateway\TableGatewayInterface;
use RuntimeException;
class TaskTable
{
/** @var TableGatewayInterface */
private $tableGateway;
public function __construct(TableGatewayInterface $tableGateway)
{
$this->tableGateway = $tableGateway;
}
public function fetchAll()
{
return $this->tableGateway->select();
}
public function getTask($id)
{
$id = (int)$id;
$rowset = $this->tableGateway->select(['id' => $id]);
$row = $rowset->current();
if (!$row) {
throw new RuntimeException(sprintf(
'Could not find row with identifier %d',
$id
));
}
return $row;
}
}
En el anterior código lo primero que hacemos es definir la propiedad privada $tableGateway
. Después definimos el constructor pasándole una instancia de la interfaz TableGatewayInterface
. Usaremos esto para ejecutar operaciones en la tabla de la base de datos.
A continuación definimos los siguientes métodos:
- fetchAll(): regresa todos las tareas.
-
getTask(): acepta un parámetro
$id
para regresar una tarea en específico y sino lo encuentra regresa una excepción del tipoRuntimeException
.
Ahora necesitamos configurar e inyectar en el Controlador estas clases para poder interactuar con la base de datos.
Para poder configurar el ServiceManager podemos proporcionar el nombre de la clase a instanciar o una fábrica (una función anónima o closure, callback o el nombre de clase de una fábrica), el cual instancia al objeto cuando el ServiceManager lo necesita.
Vamos a agregar el método getServiceConfig()
para proporcionar una fábrica que crea un objeto AlbumTable. Agregamos este método al final del archivo TodosApp/src/Module.php
use Laminas\Db\Adapter\AdapterInterface;
use Laminas\Db\ResultSet\ResultSet;
use Laminas\Db\TableGateway\TableGateway;
use Laminas\ModuleManager\Feature\ConfigProviderInterface;
use TodosApp\Model\Task;
use TodosApp\Model\TaskTable;
public function getServiceConfig(): array
{
return [
'factories' => [
'TaskTableGateway' => function ($sm) {
$dbAdapter = $sm->get(AdapterInterface::class);
$resultSetPrototype = new ResultSet();
$resultSetPrototype->setArrayObjectPrototype(new Task());
return new TableGateway('task',$dbAdapter, null, $resultSetPrototype);
},
'TodosApp\Model\TaskTable' => function ($sm) {
$tableGateWay = $sm->get('TaskTableGateway');
return new TaskTable($tableGateWay);
}
]
];
}
En el código anterior en la clave del array factories agregamos una fábrica llamada TaskTableGateway que tiene como valor una función anónima (closure). Cuando configuramos los parámetros de conexión en el config/autoload/global.php definimos un adaptador el cual está disponible en la aplicación. Con el adaptador definido podemos recuperar el adaptador:
$dbAdapter = $sm->get(AdapterInterface::class);
Y creamos un objeto de tipo TableGateway donde le indicamos que use la tablas task, el adaptador de la base de datos y un objeto $resultSetPrototipe
para que cree una nueva fila de resultados. La clase TableGateway usa el patrón de prototipo para la creación de conjuntos de datos y de entidades. Lo que significa que en lugar de crear instancias cuando sea necesario, el sistema clona un objeto previamente instanciado.
La otra fábrica que se crea es TodosApp\Model\TaskTable, la cual obtiene una instancia de la fábrica TaskTableGateway
y regresa un objeto de TaskTable pasándole el objeto $tableGateWay
donde va la configuración a la base de datos y el nombre de la tabla a usar.
Inyectando TaskTable al Controller
Como el Controller debe utilizar la clase TaskTable para poder interactuar con la base de datos, decimos que depende de esta. Ahora vamos a crear una fábrica para inyectar la clase TaskTable al controller, en el mismos archivo TodosApp/src/Module.php agregamos lo siguiente:
public function getControllerConfig() :array
{
return [
'factories' => [
Controller\ToDoController::class => function ($container) {
return new Controller\ToDoController(
$container->get(Model\TaskTable::class)
);
}
]
];
}
Básicamente lo que hacemos aquí es inyectarle una instancia de TaskTable al controller, se ve en la línea:
return new Controller\ToDoController($container->get(Model\TaskTable::class));
Ya que definimos nuestra fábrica ahora podemos remover la llave “controllers” del archivo TodosApp/config/module.config.php
A continuación vamos a modificar nuestro controller. Y vamos a agregar el constructor como sigue:
/** @var TaskTable */
private $table;
public function __construct($table)
{
$this->table = $table;
}
Aquí ya estamos inyectando la dependencia de TaskTable a nuestro controller para que podamos usarlo en todos nuestras acciones.
Listado de Task
Abrimos el TodoController y en la acción IndexAction para que se vea como esto:
public function indexAction(): ViewModel
{
$tasks = $this->table->fetchAll();
return new ViewModel(['tasks' => $tasks]);
}
En el código anterior estamos recuperando todas las task y el resultado se lo pasamos a la vista mediante la variable tasks. En la vista vamos agregar lo siguiente para poder ver todas las tareas, abrimos el archivo index.phtml y modificamos:
<h4 class="d-flex justify-content-between align-items-center mb-3 mt-3">
<span class="text-muted">Todas las tareas</span>
<a class="btn btn-sm btn-info" href="<?= $this->url('todo-app', ['action' => 'create'])?>">Crear</a>
</h4>
<table class="table table-hover">
<thead>
<tr>
<th>Título</th>
<th>Fecha de creación</th>
<th>Fecha de finalización</th>
<th>Completada</th>
<td>Operaciones</td>
</tr>
</thead>
<?php foreach ($this->tasks as $task) : ?>
<tr>
<td><?= $this->escapeHtml($task->title) ?></td>
<td><?= $this->escapeHtml($task->creationDate) ?></td>
<td><?= $this->escapeHtml($task->finishDate) ?></td>
<td><?= $task->finished ? 'Si' : 'No'?></td>
<td>
<span class="ml-1">Editar</span>
<span class="ml-1">Ver</span>
<span class="ml-1">Eliminar</span>
</td>
</tr>
<?php endforeach; ?>
</table>
En la parte inicial del archivo agregue un encabezado h4 para la página, en este caso Todas las tareas, enseguida un botón que nos llevará al formulario de creación de una nueva tarea. El helper $this->url()
, se usa para generar los links para crear los enlaces que necesitamos, en este caso el de creación de una tarea nueva. El primer parámetro de url(), es el nombre de la ruta; en esta caso “todo-app”, el segundo parámetro es un array de variables para sustituir los placeholders los cuales son el nombre del action llamado “create”. Lo cual genera una ruta así: /todo-app/create
.
Para listar las tareas lo hacemos mediante una tabla de HTML, primero defino el nombre de las columnas:
- Título.
- Fecha de creación.
- Fecha de finalización.
- Completada.
- Operaciones: para poner las diferentes acciones como, editar, ver y eliminar la tarea.
Y a continuación mediante un ciclo foreach
voy imprimiendo cada una de las columnas, como sabemos cada task tiene las siguientes propiedades: title, creationDate, finishDate y finished. Se usa la sintaxis alternativa del bucle usando foreach:
y terminando con endforeach;
ya que es más fácil identificar un bloque de código que hacerlo con llaves {}
.
El helper escapeHtml
, se usa para protegernos de las vulnerabilidades de Cross Site Scripting (XSS); nunca se debe confiar en lo que el usuario ingresa a la base de datos mediante formularios.
Si abres tu navegador en la dirección de la aplicación http://localhost:8081/todo-app, deberías ver esto:
Es todo por ahora en la tercera parte vamos a continuar con el registro de una nueva task de nuestro CRUD.
Saludos y happy coding :)