If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeUna nueva característica se coló en Doctrine hace un tiempo, y es súper genial. Ahora Doctrine puede adivinar alguna configuración sobre una propiedad a través de su tipo. Empezaremos con las propiedades de relación. Pero antes, quiero asegurarme de que mi base de datos está sincronizada con mis entidades. Ejecuta:
symfony console doctrine:schema:update --dump-sql
Y... ¡sí! Mi base de datos sí se parece a mis entidades. Volveremos a ejecutar este comando más tarde, después de hacer un montón de cambios... porque nuestro objetivo no es realmente cambiar nada de la configuración de nuestra base de datos: sólo simplificarla. Ah, y sí, esto ha volcado un montón de depreciaciones... las arreglaremos... eventualmente... ¡Lo prometo!
Este es el cambio número uno. Esta propiedad question
contiene un objeto Question
. Así que vamos a añadir un tipo Question
. Pero tenemos que tener cuidado. Tiene que ser un Question
anulable. Aunque sea necesario en la base de datos, después de instanciar el objeto, la propiedad no se rellenará instantáneamente: al menos temporalmente, no se establecerá. Verás que hago esto con todos mis tipos de propiedades de entidad. Si es posible que una propiedad sea null
-aunque sea por un momento- debemos reflejarlo.
... lines 1 - 9 | |
class Answer | |
{ | |
... lines 12 - 33 | |
private ?Question $question = null; | |
... lines 35 - 120 | |
} |
También voy a inicializar esto con = null
. Si eres nuevo en los tipos de propiedad, esto es lo que pasa. Si añades un tipo a una propiedad... y luego intentas acceder a ella antes de que esa propiedad se haya establecido en algún valor, obtendrás un error, como
No se puede acceder a la propiedad de tipo Answer::$question antes de la inicialización.
Sin un tipo de propiedad, el = null
no es necesario, pero ahora sí. Gracias a esto, si instanciamos un Answer
y luego llamamos a getQuestion()
antes de que se establezca esa propiedad, las cosas no explotarán.
Vale, añadir tipos de propiedades está bien: hace que nuestro código sea más limpio y ajustado. Pero hay otra gran ventaja: ¡ya no necesitamos el targetEntity
! Ahora Doctrine es capaz de resolverlo por nosotros. Así que borra esto... ¡y celébralo!
... lines 1 - 9 | |
class Answer | |
{ | |
... lines 12 - 31 | |
#[ORM\ManyToOne(inversedBy: 'answers')] | |
... line 33 | |
private ?Question $question = null; | |
... lines 35 - 120 | |
} |
Entonces... sigue yendo a Question
. Estoy buscando específicamente campos de relación. Éste es un OneToMany
, que contiene una colección de $answers
. Vamos a añadir un tipo aquí... pero en un minuto. Centrémonos primero en las relaciones de ManyToOne
.
Aquí abajo, en owner
, añade ?User
, $owner = null
, y luego deshazte de targetEntity
.
... lines 1 - 13 | |
class Question | |
{ | |
... lines 16 - 47 | |
#[ORM\ManyToOne(inversedBy: 'questions')] | |
... line 49 | |
private ?User $owner = null; | |
... lines 51 - 219 | |
} |
Y luego en QuestionTag
, haz lo mismo: ?Question $question = null
... y da la vuelta de la victoria eliminando targetEntity
.
... lines 1 - 8 | |
class QuestionTag | |
{ | |
... lines 11 - 15 | |
#[ORM\ManyToOne(inversedBy: 'questionTags')] | |
... line 17 | |
private ?Question $question = null; | |
... lines 19 - 71 | |
} |
Y... aquí abajo... ¡una vez más! ?Tag
$tag = null... y despídete detargetEntity
.
... lines 1 - 8 | |
class QuestionTag | |
{ | |
... lines 11 - 19 | |
#[ORM\ManyToOne()] | |
... line 21 | |
private ?Tag $tag = null; | |
... lines 23 - 71 | |
} |
¡Qué bien! Para asegurarnos de que no hemos estropeado nada, vuelve a ejecutar el comando schema:update
de antes:
symfony console doctrine:schema:update --dump-sql
Y... ¡todavía estamos bien!
Bien, vayamos más allá y añadamos tipos a todas las propiedades. Esto supondrá más trabajo, pero el resultado merece la pena. En el caso de $id
, será un int
anulable... y lo inicializaremos a null
. Gracias a ello, no necesitamos type: 'integer'
: Doctrine ya puede resolverlo.
... lines 1 - 9 | |
class Answer | |
{ | |
... lines 12 - 19 | |
#[ORM\Column()] | |
private ?int $id = null; | |
... lines 22 - 120 | |
} |
Para $content
, una cadena anulable... con = null
. Pero en este caso, sí necesitamos mantener type: 'text'
. Cuando Doctrine ve el tipo string
, adivinatype: 'string'
... que contiene un máximo de 255 caracteres. Como este campo contiene mucho texto, anula la suposición con type: 'text'
.
... lines 1 - 9 | |
class Answer | |
{ | |
... lines 12 - 22 | |
#[ORM\Column(type: 'text')] | |
private ?string $content = null; | |
... lines 25 - 120 | |
} |
Por cierto, algunos os preguntaréis por qué no uso $content = ''
en su lugar. Diablos, ¡entonces podríamos eliminar la nulidad de ?
en el tipo! ¡Es una buena pregunta! La razón es que este campo es obligatorio en la base de datos. Si inicializamos la propiedad a comillas vacías... y tengo un error en mi código por el que me olvidé de establecer la propiedad $content
, se guardaría con éxito en la base de datos con el contenido establecido en una cadena vacía. Al inicializarlo a null
, si nos olvidamos de establecer este campo, explotará antes de entrar en la base de datos. Entonces, podemos arreglar ese error... en lugar de que guarde silenciosamente la cadena vacía. Puede que sea furtivo, pero nosotros lo somos más.
Bien, ¡continuemos! Gran parte de esto será un trabajo intenso... así que avancemos lo más rápido posible. Añade el tipo a username
... y elimina la opción Doctrina type
. También podemos eliminar length
... ya que el valor por defecto siempre ha sido 255
. La propiedad $votes
se ve bien, pero podemos deshacernos de type: 'integer'
. Y aquí abajo para $status
, esto ya tiene el tipo, así que elimina type: 'string'
. Pero tenemos que mantener el length
si queremos que sea más corto que el 255.
... lines 1 - 9 | |
class Answer | |
{ | |
... lines 12 - 25 | |
#[ORM\Column()] | |
private ?string $username = null; | |
#[ORM\Column()] | |
private int $votes = 0; | |
... lines 31 - 35 | |
#[ORM\Column(length: 15)] | |
private string $status = self::STATUS_NEEDS_APPROVAL; | |
... lines 38 - 120 | |
} |
Pasamos a la entidad Question
. Dale a $id
el tipo... elimina su opción type
Doctrina, actualiza $name
... elimina todas sus opciones.... y repite esto para $slug
. Observa que $slug
todavía utiliza una anotación de @Gedmo\Slug
. Lo arreglaremos en un minuto.
Actualiza $question
... y luego $askedAt
. Esto es un type: 'datetime'
, así que va a contener una instancia de ?\DateTime
. También la inicializaré a null. Ah, y me olvidé de hacerlo, pero ahora podemos eliminar type: 'datetime'
.
... lines 1 - 13 | |
class Question | |
{ | |
... lines 16 - 19 | |
#[ORM\Column()] | |
private ?int $id = null; | |
#[ORM\Column()] | |
private ?string $name = null; | |
/** | |
* @Gedmo\Slug(fields={"name"}) | |
*/ | |
#[ORM\Column(length: 100, unique: true)] | |
private ?string $slug = null; | |
#[ORM\Column(type: 'text')] | |
private ?string $question = null; | |
#[ORM\Column(nullable: true)] | |
private ?\DateTime $askedAt = null; | |
... lines 37 - 219 | |
} |
Y ahora volvemos a la relación OneToMany
. Si miras hacia abajo, esto se inicializa en el constructor a un ArrayCollection
. Así que podrías pensar que deberíamos usar ArrayCollection
para el tipo. Pero en su lugar, digamos Collection
.
Esa es una interfaz de Doctrine que implementa ArrayCollection
. Tenemos que utilizar Collection
aquí porque, cuando busquemos un Question
en la base de datos y obtengamos la propiedad $answers
, Doctrine la establecerá en un objeto diferente: un PersistentCollection
. Así que esta propiedad puede ser un ArrayCollection
, o un PersistentCollection
... pero en todos los casos, implementará esta interfazCollection
. Y esto no necesita ser anulable porque se inicializa dentro del constructor. Haz lo mismo con $questionTags
.
... lines 1 - 13 | |
class Question | |
{ | |
... lines 16 - 42 | |
private Collection $answers; | |
... lines 44 - 45 | |
private Collection $questionTags; | |
... lines 47 - 219 | |
} |
Aunque no lo creas, ¡estamos en la recta final! En QuestionTag
... haz nuestros cambios habituales en $id
... y luego baja a $taggedAt
. Este es un tipo datetime_immutable
, así que utiliza \DateTimeImmutable
. Fíjate en que no lo he hecho anulable y no lo estoy inicializando a null. Eso es simplemente porque lo estamos estableciendo en el constructor. Así nos garantizamos que siempre contendrá una instancia de\DateTimeImmutable
: nunca será nula.
... lines 1 - 8 | |
class QuestionTag | |
{ | |
... lines 11 - 12 | |
#[ORM\Column()] | |
private ?int $id = null; | |
... lines 15 - 23 | |
#[ORM\Column()] | |
private \DateTimeImmutable $taggedAt; | |
... lines 26 - 71 | |
} |
Bien, ahora a Tag
. Haz nuestro habitual baile de $id
. Pero espera... en QuestionTag
, me olvidé de quitar el type: 'integer'
. No hace nada... simplemente no es necesario. Y... lo mismo para type: 'datetime_immutable
.
De vuelta en Tag
, sigamos con la propiedad $name
... esto es todo normal...
... lines 1 - 9 | |
class Tag | |
{ | |
... lines 12 - 15 | |
#[ORM\Column()] | |
private ?int $id = null; | |
#[ORM\Column()] | |
private ?string $name = null; | |
... lines 21 - 37 | |
} |
Luego salta a nuestra última clase: User
. Aceleraré los aburridos cambios en $id
y $email
... y $password
. Eliminemos también el PHP Doc de @var
que está por encima de éste: ahora es totalmente redundante. Hagamos lo mismo con $plainPassword
. Diablos, este @var
ni siquiera estaba bien - ¡debería haber sido string|null
!
Vamos a hacer un acercamiento a los últimos cambios: $firstName
, añade Collection
a$questions
... y no hace falta type
para $isVerified
.
... lines 1 - 13 | |
class User implements UserInterface | |
{ | |
... lines 16 - 17 | |
#[ORM\Column()] | |
private ?int $id = null; | |
#[ORM\Column(length: 180, unique: true)] | |
private ?string $email = null; | |
... lines 23 - 29 | |
#[ORM\Column(type: 'string')] | |
private ?string $password = null; | |
/** | |
* Non-mapped field | |
*/ | |
private ?string $plainPassword = null; | |
#[ORM\Column()] | |
private ?string $firstName = null; | |
#[ORM\OneToMany(targetEntity: Question::class, mappedBy: 'owner')] | |
private Collection $questions; | |
#[ORM\Column(type: 'boolean')] | |
private bool $isVerified = false; | |
... lines 46 - 210 | |
} |
Y... ¡hemos terminado! Esto ha sido una faena. Pero en adelante, el uso de tipos de propiedades significará un código más ajustado... y menos configuración de Doctrine.
Pero... veamos si hemos estropeado algo. Ejecuta doctrine:schema:update
por última vez:
symfony console doctrine:schema:update --dump-sql
¡Está limpio! Hemos cambiado una tonelada de configuración, pero en realidad no ha cambiado cómo se mapea ninguna de nuestras entidades. Misión cumplida.
Ah, y como prometimos, hay una última anotación que tenemos que cambiar: está en la entidad Question
, encima del campo $slug
. Proviene de la biblioteca de extensiones de Doctrine. El rector no lo ha actualizado... pero es súper fácil. Siempre que tengas Doctrine Extensions 3.6 o superior, puedes utilizarlo como atributo. Así que#[Gedmo\Slug()]
con una opción fields
que tenemos que establecer en un array. Lo bueno de los atributos PHP es que... ¡sólo son código PHP! Así que escribir un array en atributos... es lo mismo que escribir un array en PHP. Dentro, pasa 'name'
... usando comillas simples, como solemos hacer en PHP.
... lines 1 - 9 | |
use Gedmo\Mapping\Annotation as Gedmo; | |
... lines 11 - 13 | |
class Question | |
{ | |
... lines 16 - 25 | |
fields: ['name']) ( | |
#[ORM\Column(length: 100, unique: true)] | |
private ?string $slug = null; | |
... lines 29 - 217 | |
} |
Bien, equipo: acabamos de dar un gran paso adelante en nuestro código base. A continuación, vamos a centrarnos en las desaprobaciones restantes y a trabajar para aplastarlas. Vamos a empezar con el elefante en la habitación: la conversión al nuevo sistema de seguridad. Pero no te preocupes Es más fácil de lo que crees
Hey Annemieke-B!
Excellent question! I might... suggest something totally different. There is a general "best practice" out there (I don't know if it's programming-wide or just in PHP, due to our types) to avoid decimals and floats with prices. Instead, use integers.
The idea is that you store your costPrice
in whatever the lowest denomination is of the currency - in other words, you store it in "cents". If something costs $45.33, you would store the integer 4533
in the database. This avoids weird things like getting strings or rounding issue (actually, the reason that it's giving you a string is to try to avoid rounding issues - e.g. 45.33 becoming something weird like 45.33000000000001
or something crazy like that). To help when rendering, you can add another getter method like getCostPriceFormatted()
where you return $this->costPrice / 100
or even add a $
in front of that and return a string.
Let me know if this helps - we've absolutely done this on our site :).
Cheers!
Cheers!
Thank you Ryan for the quick response.
I knew about the cents option, that works great. Using it when I can and had some discussions about that with fellow programmers in the past.
So, this was a bad example i send you...., sorry.
So what about data that has decimals, because of other kinds of units and needs to be imported via a cronjob/command? E.g. laboratory data, with all kinds of units i have no knowledge of.
I'd really like to know how you would handle this.
Thank you!
Annemieke
Hi @Annemieke!
Sorry for my very slow reply!
E.g. laboratory data, with all kinds of units i have no knowledge of.
Hmm. I don't have a lot of first-hand experience with floating-point calculations in PHP. But I do know that the reason Doctrine returns a string
with a decimal
type is exactly because it doesn't trust php to keep the exact precision if it returns it as a double
. From their docs:
For compatibility reasons this type (
decimal
) is not converted to a double as PHP can only preserve the precision to a certain degree. Otherwise it approximates precision which can lead to false assumptions in applications.
And the float
Doctrine type is not meant for exact precision. So, double
seems correct to me from a Doctrine perspective... simply because it uses a string, so we don't have the precision problem with using an actual number type in PHP. Then, if you need to do more than just print that number (e.g. you need to do some calculations), I imagine that is why php-decimal
exists.
I hope this helps :).
Cheers!
When adding property types in entities PhpStorm has a wonderful little helper to do them all in one go (on a file by file basis). Are you not using this so we learn how to do them ourselves or is it just not wise to do it that?
Steve
Howdy!
If I have a class like:
class One
{
public $alpha;
public $beta;
public function __construct(string $alpha, array $beta)
{
$this->alpha = $alpha;
$this->beta = $beta;
}
}
I believe Steve is referring to the ability to right-click on a property ($alpha
) -> selecting Show Context Actions
-> selecting the "arrow" next to Add 'string' as the property's type
-> selecting "Fix all 'Missing property's type declaration' problems in file.
Enjoy!
This does do the same thing, I think I'm so used to using the keys and that is how I came upon the fix but now I know this route I may change... Adopt Adapt and Improve :)
Cheers Jesse
In an entity class PHPStorm under lines properties if there is no property type specified.
I place the cursor on the property in question then (I'm on a Mac) hold down the option key and press enter
A menu pops up and the first option is "add [INSERT TYPE] as the property type. You can select this to complete or press the right arrow to expand the menu. The second option in this menu allows you to "Fix all missing property type" within the class
I hope I've explained this correctly.
Steve
Hey Steve D.
Good observation. Using PHPStorm to autocomplete your code is very useful, I use it as much as I can so I can save time and focus on the important things. I think Ryan don't use it too much avoid confusion and show exactly what's he doing
Cheers!
// composer.json
{
"require": {
"php": "^8.0.2",
"ext-ctype": "*",
"ext-iconv": "*",
"babdev/pagerfanta-bundle": "^3.6", // v3.6.1
"composer/package-versions-deprecated": "^1.11", // 1.11.99.5
"doctrine/annotations": "^1.13", // 1.13.2
"doctrine/dbal": "^3.3", // 3.3.5
"doctrine/doctrine-bundle": "^2.0", // 2.6.2
"doctrine/doctrine-migrations-bundle": "^3.2", // 3.2.2
"doctrine/orm": "^2.0", // 2.11.2
"knplabs/knp-markdown-bundle": "^1.8", // 1.10.0
"knplabs/knp-time-bundle": "^1.18", // v1.18.0
"pagerfanta/doctrine-orm-adapter": "^3.6", // v3.6.1
"pagerfanta/twig": "^3.6", // v3.6.1
"sensio/framework-extra-bundle": "^6.0", // v6.2.6
"sentry/sentry-symfony": "^4.0", // 4.2.8
"stof/doctrine-extensions-bundle": "^1.5", // v1.7.0
"symfony/asset": "6.0.*", // v6.0.7
"symfony/console": "6.0.*", // v6.0.7
"symfony/dotenv": "6.0.*", // v6.0.5
"symfony/flex": "^2.1", // v2.1.7
"symfony/form": "6.0.*", // v6.0.7
"symfony/framework-bundle": "6.0.*", // v6.0.7
"symfony/mailer": "6.0.*", // v6.0.5
"symfony/monolog-bundle": "^3.0", // v3.7.1
"symfony/property-access": "6.0.*", // v6.0.7
"symfony/property-info": "6.0.*", // v6.0.7
"symfony/proxy-manager-bridge": "6.0.*", // v6.0.6
"symfony/routing": "6.0.*", // v6.0.5
"symfony/runtime": "6.0.*", // v6.0.7
"symfony/security-bundle": "6.0.*", // v6.0.5
"symfony/serializer": "6.0.*", // v6.0.7
"symfony/stopwatch": "6.0.*", // v6.0.5
"symfony/twig-bundle": "6.0.*", // v6.0.3
"symfony/ux-chartjs": "^2.0", // v2.1.0
"symfony/validator": "6.0.*", // v6.0.7
"symfony/webpack-encore-bundle": "^1.7", // v1.14.0
"symfony/yaml": "6.0.*", // v6.0.3
"symfonycasts/verify-email-bundle": "^1.7", // v1.10.0
"twig/extra-bundle": "^2.12|^3.0", // v3.3.8
"twig/string-extra": "^3.3", // v3.3.5
"twig/twig": "^2.12|^3.0" // v3.3.10
},
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.1
"phpunit/phpunit": "^9.5", // 9.5.20
"rector/rector": "^0.12.17", // 0.12.20
"symfony/debug-bundle": "6.0.*", // v6.0.3
"symfony/maker-bundle": "^1.15", // v1.38.0
"symfony/var-dumper": "6.0.*", // v6.0.6
"symfony/web-profiler-bundle": "6.0.*", // v6.0.6
"zenstruck/foundry": "^1.16" // v1.18.0
}
}
Hi SymfonyCasts,
I am trying to figure out what is the best way to use attributes for a decimal datatype in the database.
If I do it with
`
type: 'decimal'`
I get a string back, while i want to get a float:Now there is talk about using php-decimal, but is that really necessary?
I've also tried it with type 'float', but as I understand it correctly, it is recommended to work with decimal if you want to do calculations.
Thank you in advance.