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 SubscribeA new feature snuck into Doctrine a while back, and it's super cool. Doctrine can now guess some configuration about a property via its type. We'll start with the relationship properties. But first, I want to make sure that my database is in sync with my entities. Run:
symfony console doctrine:schema:update --dump-sql
And... yep! My database does look like my entities. We'll run this command again later after we make a bunch of changes... because our goal isn't actually to change any of our database config: just to simplify it. Oh, and yes, this dumped out a bunch of deprecations... we will fix those... eventually... I promise!
So here's change number one. This question
property holds a Question
object. So let's add a Question
type. But we have to be careful. It needs to be a nullable Question
. Even though this is required in the database, after we instantiate the object, the property won't instantly be populated: it will, at least temporarily, not be set. You'll see me do this with all of my entity property types. If it's possible for a property to be null
- even for a moment - we need to reflect that.
... lines 1 - 9 | |
class Answer | |
{ | |
... lines 12 - 33 | |
private ?Question $question = null; | |
... lines 35 - 120 | |
} |
I'm also going to initialize this with = null
. If you're new to property types, here's the deal. If you add a type to a property... then try to access it before that property has been set to some value, you'll get an error, like
Typed property Answer::$question must not be accessed before initialization.
Without a property type, the = null
isn't needed, but now it is. Thanks to this, if we instantiate an Answer
and then call getQuestion()
before that property is set, things won't explode.
Ok, so adding property types is nice: it makes our code cleaner and tighter. But, there's another big advantage: we don't need the targetEntity
anymore! Doctrine is now able to figure that out for us. So delete this... and celebrate!
... lines 1 - 9 | |
class Answer | |
{ | |
... lines 12 - 31 | |
#[ORM\ManyToOne(inversedBy: 'answers')] | |
... line 33 | |
private ?Question $question = null; | |
... lines 35 - 120 | |
} |
Then... keep going to Question
. I'm looking specifically for relationship fields. This one is a OneToMany
, which holds a collection of $answers
. We are going to add a type here... but in a minute. Let's focus on the ManyToOne
relationships first.
Down here, for owner
, add ?User
, $owner = null
, then get rid of targetEntity
.
... lines 1 - 13 | |
class Question | |
{ | |
... lines 16 - 47 | |
#[ORM\ManyToOne(inversedBy: 'questions')] | |
... line 49 | |
private ?User $owner = null; | |
... lines 51 - 219 | |
} |
And then in QuestionTag
, do the same thing: ?Question $question = null
... and do your victory lap by removing targetEntity
.
... lines 1 - 8 | |
class QuestionTag | |
{ | |
... lines 11 - 15 | |
#[ORM\ManyToOne(inversedBy: 'questionTags')] | |
... line 17 | |
private ?Question $question = null; | |
... lines 19 - 71 | |
} |
And... down here... one more time! ?Tag $tag = null
... and say bye bye to targetEntity
.
... lines 1 - 8 | |
class QuestionTag | |
{ | |
... lines 11 - 19 | |
#[ORM\ManyToOne()] | |
... line 21 | |
private ?Tag $tag = null; | |
... lines 23 - 71 | |
} |
Sweet! To make sure we didn't mess anything up, re-run the schema:update
command from earlier:
symfony console doctrine:schema:update --dump-sql
And... we're still good!
Ok, let's go further and add types to every property. This will be more work, but the result is worth it. For $id
, this will be a nullable int
... and initialize it to null
. Thanks to that, we don't need type: 'integer'
: Doctrine can now figure that out.
... lines 1 - 9 | |
class Answer | |
{ | |
... lines 12 - 19 | |
#[ORM\Column()] | |
private ?int $id = null; | |
... lines 22 - 120 | |
} |
For $content
, a nullable string... with = null
. But in this case, we do need to keep type: 'text'
. When Doctrine sees the string
type, it guesses type: 'string'
... which holds a maximum of 255 characters. Since this field holds a lot of text, override the guess with type: 'text'
.
... lines 1 - 9 | |
class Answer | |
{ | |
... lines 12 - 22 | |
#[ORM\Column(type: 'text')] | |
private ?string $content = null; | |
... lines 25 - 120 | |
} |
By the way, some of you might be wondering why I don't use $content = ''
instead. Heck, then we could remove the nullable ?
on the type! That's a good question! The reason is that this field is required in the database. If we initialize the property to empty quotes... and I had a bug in my code where I forgot to set the $content
property, it would successfully save to the database with content set to an empty string. By initializing it to null
, if we forget to set this field, it will explode before it enters the database. Then, we can fix that bug... instead of it just silently saving the empty string. It may be sneaky, but we're sneakier.
Okay, let's keep going! A lot of this will be busy work... so let's move as quickly as we can. Add the type to username
... and remove the Doctrine type
option. We can also delete length
... since the default has always been 255
. The $votes
property looks good, but we can get rid of type: 'integer'
. And down here for $status
, this already has the type, so delete type: 'string'
. But we do need to keep the length
if we want it to be shorter than 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 | |
} |
Moving on to the Question
entity. Give $id
the type... remove its type
Doctrine option, update $name
... delete all of its options.... and repeat this for $slug
. Notice that $slug
still uses an annotation from @Gedmo\Slug
. We'll fix that in a minute.
Update $question
... then $askedAt
. This is a type: 'datetime'
, so that's going to hold a ?\DateTime
instance. I'll also initialize it to null. Oh, and I forgot to do it, but we could now remove 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 | |
} |
And now we're back to the OneToMany
relationship. If you look down, this is initialized in the constructor to an ArrayCollection
. So you might think we should use ArrayCollection
for the type. But instead, say Collection
.
That's an interface from Doctrine that ArrayCollection
implements. We need to use Collection
here because, when we query for a Question
from the database and then fetch the $answers
property, Doctrine will set that to a different object: a PersistentCollection
. So this property might be an ArrayCollection
, or a PersistentCollection
... but in all cases, it will implement this Collection
interface. And this does not need to be nullable because it's initialized inside the constructor. Do the same thing for $questionTags
.
... lines 1 - 13 | |
class Question | |
{ | |
... lines 16 - 42 | |
private Collection $answers; | |
... lines 44 - 45 | |
private Collection $questionTags; | |
... lines 47 - 219 | |
} |
Believe it our not, we're in the home stretch! In QuestionTag
... make our usual $id
changes... then head down to $taggedAt
. This is a datetime_immutable
type, so use \DateTimeImmutable
. Notice that I did not make this nullable and I'm not initializing it to null. That's simply because we're setting this in the constructor. So we're guaranteed that it will always hold a \DateTimeImmutable
instance: it will never be null.
... 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 | |
} |
Ok, now to Tag
. Do our usual $id
dance. But wait... back in QuestionTag
, I forgot to remove the type: 'integer'
. It doesn't hurt anything... it's just not needed. And... same for type: 'datetime_immutable
.
Back over in Tag
, let's keep going with the $name
property... this is all normal...
... lines 1 - 9 | |
class Tag | |
{ | |
... lines 12 - 15 | |
#[ORM\Column()] | |
private ?int $id = null; | |
#[ORM\Column()] | |
private ?string $name = null; | |
... lines 21 - 37 | |
} |
Then jump to our last class: User
. I'll speed through the boring changes to $id
and $email
... and $password
. Let's also remove the @var
PHP Doc above this: that's now totally redundant. Do that same thing for $plainPassword
. Heck, this @var
wasn't even right - it should have been string|null
!
Let's zoom through the last changes: $firstName
, add Collection
to $questions
... and no type
needed for $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 | |
} |
And... we're done! This was a chore. But going forward, using property types will mean tighter code... and less Doctrine config.
But... let's see if we messed anything up. Run doctrine:schema:update
one last time:
symfony console doctrine:schema:update --dump-sql
It's clean! We changed a ton of config, but that didn't actually change how any of our entities are mapped. Mission accomplished.
Oh, and as promised, there's one last annotation that we need to change: it's in the Question
entity above the $slug
field. This comes from the Doctrine extensions library. Rector didn't update it... but it's super easy. As long as you have Doctrine Extensions 3.6 or higher, you can use this as an attribute. So #[Gedmo\Slug()]
with a fields
option that we need to set to an array. The cool thing about PHP attributes are... they're just PHP code! So writing an array in attributes... is the same as writing an array in PHP. Inside, pass 'name'
... using single quotes, just like we usually do in 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 | |
} |
Ok team: we just took our codebase a huge step forward. Next, let's dial in on these remaining deprecations and work on squashing them. We're going to start with the elephant in the room: converting to the new security system. But don't worry! It's easier than you might think!
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.