SymfonyWorld Online 2020
100% online
30+ talks + workshops
Live + Replay watch talks later

Paso 13: Gestionando el ciclo de vida de los objetos de Doctrine

5.0 version
Maintained

Gestionando el ciclo de vida de los objetos de Doctrine

Al crear un nuevo comentario, sería estupendo que la fecha createdAt se ajustara automáticamente con la fecha y hora actual.

Doctrine tiene diferentes maneras de manipular los objetos y sus propiedades durante su ciclo de vida (antes de que se cree la fila en la base de datos, después de que se actualice la fila…)

Definiendo callbacks del ciclo de vida

Cuando el comportamiento no necesita ningún servicio y debe ser aplicado a un solo tipo de entidad, define un callback en la clase de la entidad:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
--- a/src/Entity/Comment.php
+++ b/src/Entity/Comment.php
@@ -7,6 +7,7 @@ use Doctrine\ORM\Mapping as ORM;

 /**
  * @ORM\Entity(repositoryClass=CommentRepository::class)
+ * @ORM\HasLifecycleCallbacks()
  */
 class Comment
 {
@@ -106,6 +107,14 @@ class Comment
         return $this;
     }

+    /**
+     * @ORM\PrePersist
+     */
+    public function setCreatedAtValue()
+    {
+        $this->createdAt = new \DateTime();
+    }
+
     public function getConference(): ?Conference
     {
         return $this->conference;

El evento @ORM\PrePersist se lanza cuando el objeto se almacena en la base de datos por primera vez. Cuando esto sucede, se llama al método setCreatedAtValue() y se utiliza la fecha y hora actual para el valor de la propiedad createdAt.

Agregando slugs a las conferencias

Las URLs de las conferencias no son útiles: /conference/1. Y lo que es más importante, dependen de un detalle de implementación (queda expuesta la clave primaria de la base de datos).

¿Qué tal si en su lugar usamos URLs como /conference/paris-2020? Eso se vería mucho mejor. paris-2020 es lo que llamamos el slug de la conferencia.

Añade una nueva propiedad slug para las conferencias (una cadena de 255 caracteres que no permita valores nulos):

1
$ symfony console make:entity Conference

Crea un archivo de migración para agregar la nueva columna:

1
$ symfony console make:migration

Y ejecuta esa nueva migración:

1
$ symfony console doctrine:migrations:migrate

¿Te has encontrado con un error? Era de esperar. ¿Por qué? Porque pedimos que el slug no aceptara valores nulos pero las entradas existentes en la base de datos de la conferencia tendrán un valor nulo cuando se ejecute la migración. Arreglemos eso ajustando la migración:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
--- a/migrations/Version00000000000000.php
+++ b/migrations/Version00000000000000.php
@@ -20,7 +20,9 @@ final class Version20200714152808 extends AbstractMigration
     public function up(Schema $schema) : void
     {
         // this up() migration is auto-generated, please modify it to your needs
-        $this->addSql('ALTER TABLE conference ADD slug VARCHAR(255) NOT NULL');
+        $this->addSql('ALTER TABLE conference ADD slug VARCHAR(255)');
+        $this->addSql("UPDATE conference SET slug=CONCAT(LOWER(city), '-', year)");
+        $this->addSql('ALTER TABLE conference ALTER COLUMN slug SET NOT NULL');
     }

     public function down(Schema $schema) : void

El truco aquí es agregar la columna y permitirle que acepte valores nulos, luego asignar a slug un valor no nulo, y finalmente, cambiar la columna de slug para no permitir valores nulos.

Nota

Para un proyecto real, el uso de CONCAT(LOWER(city), '-', year) puede que no sea suficiente. En ese caso, necesitaríamos usar el Slugger «verdadero».

La migración debería funcionar bien ahora:

1
$ symfony console doctrine:migrations:migrate

Debido a que la aplicación pronto usará slugs para encontrar cada conferencia, ajustemos la entidad Conference para asegurar que los valores de slug sean únicos en la base de datos:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
--- a/src/Entity/Conference.php
+++ b/src/Entity/Conference.php
@@ -5,9 +5,11 @@ namespace App\Entity;
 use Doctrine\Common\Collections\ArrayCollection;
 use Doctrine\Common\Collections\Collection;
 use Doctrine\ORM\Mapping as ORM;
+use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;

 /**
  * @ORM\Entity(repositoryClass=ConferenceRepository::class)
+ * @UniqueEntity("slug")
  */
 class Conference
 {
@@ -39,7 +41,7 @@ class Conference
     private $comments;

     /**
-     * @ORM\Column(type="string", length=255)
+     * @ORM\Column(type="string", length=255, unique=true)
      */
     private $slug;

Como habrás adivinado, necesitamos realizar la danza de la migración:

1
$ symfony console make:migration
1
$ symfony console doctrine:migrations:migrate

Generando slugs

Generar un slug que se lea bien en una URL (donde cualquier cosa que no sean caracteres ASCII debe ser codificada) es una tarea desafiante, especialmente para idiomas que no sean el inglés. Por ejemplo, ¿Cómo conviertes é a e?

En lugar de reinventar la rueda, usemos el componente de Symfony String, que facilita la manipulación de las cadenas y proporciona un slugger:

1
$ symfony composer req string

Añade un método computeSlug() a la clase Conference que calcule el slug basado en los datos de la conferencia:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
--- a/src/Entity/Conference.php
+++ b/src/Entity/Conference.php
@@ -6,6 +6,7 @@ use Doctrine\Common\Collections\ArrayCollection;
 use Doctrine\Common\Collections\Collection;
 use Doctrine\ORM\Mapping as ORM;
 use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
+use Symfony\Component\String\Slugger\SluggerInterface;

 /**
  * @ORM\Entity(repositoryClass=ConferenceRepository::class)
@@ -60,6 +61,13 @@ class Conference
         return $this->id;
     }

+    public function computeSlug(SluggerInterface $slugger)
+    {
+        if (!$this->slug || '-' === $this->slug) {
+            $this->slug = (string) $slugger->slug((string) $this)->lower();
+        }
+    }
+
     public function getCity(): ?string
     {
         return $this->city;

El método computeSlug() sólo calcula un slug cuando el actual está vacío o ajustado al valor especial -. ¿Por qué necesitamos el valor especial -? Porque cuando se agrega una conferencia en el backend, se requiere el slug. Por lo tanto, necesitamos un valor no vacío que le diga a la aplicación que queremos que el slug se genere automáticamente.

Definiendo un callback de ciclo de vida complejo

Al igual que con la propiedad createdAt, el slug debe ser configurado automáticamente cada vez que se actualice la conferencia llamando al método computeSlug().

Pero como este método depende de una implementación SluggerInterface, no podemos añadir un evento prePersist como antes (no tenemos una forma de inyectar el slugger).

En su lugar, crea un oyente de entidades de Doctrine:

src/EntityListener/ConferenceEntityListener.php
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
namespace App\EntityListener;

use App\Entity\Conference;
use Doctrine\ORM\Event\LifecycleEventArgs;
use Symfony\Component\String\Slugger\SluggerInterface;

class ConferenceEntityListener
{
    private $slugger;

    public function __construct(SluggerInterface $slugger)
    {
        $this->slugger = $slugger;
    }

    public function prePersist(Conference $conference, LifecycleEventArgs $event)
    {
        $conference->computeSlug($this->slugger);
    }

    public function preUpdate(Conference $conference, LifecycleEventArgs $event)
    {
        $conference->computeSlug($this->slugger);
    }
}

Ten en cuenta que el slug se actualiza cuando se crea una nueva conferencia (prePersist()) y cuando se actualiza (preUpdate()).

Configurando un servicio en el contenedor

Hasta ahora, no hemos hablado de un componente clave de Symfony, el contenedor de inyección de dependencias. El contenedor se encarga de gestionar los servicios: crearlos e inyectarlos cuando sea necesario.

Un servicio es un objeto «global» que proporciona características (por ejemplo, un mailer, un logger, un slugger, etc.) a diferencia de los objetos de datos (por ejemplo, instancias de entidades de Doctrine).

Rara vez interactúas con el contenedor directamente, ya que inyecta automáticamente objetos de esos servicios siempre que los necesites: cuando indicas el nombre de la clase que provee el servicio (type-hinting), el contenedor inyecta los objetos en los parámetros del controlador.

Si te preguntabas cómo se registró el oyente del evento en el paso anterior, ahora tienes la respuesta: el contenedor. Cuando una clase implementa algunas interfaces específicas, el contenedor sabe que la clase necesita ser registrada de cierta manera.

Desafortunadamente, la automatización no está prevista para todo, especialmente para los paquetes de terceros. El oyente de entidades que acabamos de escribir es un ejemplo de ello; no puede ser gestionado automáticamente por el contenedor de servicios de Symfony ya que no implementa ninguna interfaz y no extiende una «clase bien conocida».

Necesitamos declarar parcialmente al oyente en el contenedor. El cableado de dependencias se puede omitir ya que todavía se puede adivinar por el contenedor, pero necesitamos agregar manualmente algunas etiquetas para registrar al oyente con el despachador de eventos de Doctrine:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
--- a/config/services.yaml
+++ b/config/services.yaml
@@ -25,3 +25,7 @@ services:

     # add more service definitions when explicit configuration is needed
     # please note that last definitions always *replace* previous ones
+    App\EntityListener\ConferenceEntityListener:
+        tags:
+            - { name: 'doctrine.orm.entity_listener', event: 'prePersist', entity: 'App\Entity\Conference'}
+            - { name: 'doctrine.orm.entity_listener', event: 'preUpdate', entity: 'App\Entity\Conference'}

Nota

No confundas a los oyentes de los eventos de Doctrine con los de Symfony. Aunque parezcan muy similares, no están utilizando la misma infraestructura realmente.

Usando slugs en la aplicación

Intenta añadir más conferencias en el módulo de servicio y cambia la ciudad o el año de una existente; el slug no se actualizará excepto si utilizas el valor especial -.

El último cambio es actualizar los controladores y las plantillas para utilizar el slug de la conferencia en lugar del id de la conferencia para las rutas:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -31,7 +31,7 @@ class ConferenceController extends AbstractController
     }

     /**
-     * @Route("/conference/{id}", name="conference")
+     * @Route("/conference/{slug}", name="conference")
      */
     public function show(Request $request, Conference $conference, CommentRepository $commentRepository)
     {
--- a/templates/base.html.twig
+++ b/templates/base.html.twig
@@ -10,7 +10,7 @@
             <h1><a href="{{ path('homepage') }}">Guestbook</a></h1>
             <ul>
             {% for conference in conferences %}
-                <li><a href="{{ path('conference', { id: conference.id }) }}">{{ conference }}</a></li>
+                <li><a href="{{ path('conference', { slug: conference.slug }) }}">{{ conference }}</a></li>
             {% endfor %}
             </ul>
             <hr />
--- a/templates/conference/show.html.twig
+++ b/templates/conference/show.html.twig
@@ -22,10 +22,10 @@
         {% endfor %}

         {% if previous >= 0 %}
-            <a href="{{ path('conference', { id: conference.id, offset: previous }) }}">Previous</a>
+            <a href="{{ path('conference', { slug: conference.slug, offset: previous }) }}">Previous</a>
         {% endif %}
         {% if next < comments|length %}
-            <a href="{{ path('conference', { id: conference.id, offset: next }) }}">Next</a>
+            <a href="{{ path('conference', { slug: conference.slug, offset: next }) }}">Next</a>
         {% endif %}
     {% else %}
         <div>No comments have been posted yet for this conference.</div>
--- a/templates/conference/index.html.twig
+++ b/templates/conference/index.html.twig
@@ -8,7 +8,7 @@
     {% for conference in conferences %}
         <h4>{{ conference }}</h4>
         <p>
-            <a href="{{ path('conference', { id: conference.id }) }}">View</a>
+            <a href="{{ path('conference', { slug: conference.slug }) }}">View</a>
         </p>
     {% endfor %}
 {% endblock %}

El acceso a las páginas de la conferencia debe realizarse ahora a través de su slug:


  • « Previous Paso 12: Escuchando eventos
  • Next » Paso 14: Obteniendo realimentación con formularios

This work, including the code samples, is licensed under a Creative Commons BY-NC-SA 4.0 license.