Adding Property Types to Entities
Keep on Learning!
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!
Removing targetEntity
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!
Adding Types to All Properties
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 | |
} |
Initialize string Field to null or ''?
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 | |
} |
Typing Collection Properties
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.
Updating Gedmo\Slug Annotation
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!
Hi, looking in rector to ajust my entities (+100 entities) like this video i found these
Should I use this or is there any other way?