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 SubscribeI really like adding timestampable behavior to my entities. That's where you have $createdAt
and $updatedAt
properties that are set automatically. It just... helps keep track of when things happened. We added $createdAt
and cleverly set it by hand in the constructor. But what about $updatedAt
? Doctrine does have an awesome event system, and we could hook into that to run code on "update" that sets that property. But there's a library that already does that. So let's get it installed.
At your terminal, run:
composer require stof/doctrine-extensions-bundle
This installs a small bundle, which is a wrapper around a library called DoctrineExtensions. Like a lot of packages, this includes a recipe. But this is the first recipe that comes from the "contrib" repository. Remember: Symfony actually has two repositories for recipes. There's the main one, which is closely guarded by the Symfony core team. Then another called recipes-contrib
. There are some quality checks on that repository, but it's maintained by the community. The first time that Symfony installs a recipe from the "contrib" repository, it asks you if that's okay. I'm going to say p
for "yes permanently". Then run:
git status
Awesome! It enabled a bundle and added a new configuration file that we'll look at in a second.
So this bundle obviously has its own documentation. You can search for stof/doctrine-extensions-bundle
and find it on Symfony.com. But the majority of the docs live on the underlying DoctrineExtensions library... which contains a bunch of really cool behaviors, including "sluggable" and "timestampable". Let's add "timestampable" first.
Step one: go into config/packages/
and open the configuration file it just added. Here, add orm
because we're using Doctrine ORM, then default
, and lastly timestampable: true
.
... lines 1 - 2 | |
stof_doctrine_extensions: | |
default_locale: en_US | |
orm: | |
default: | |
timestampable: true |
This won't really do anything yet. It just activates a Doctrine listener that will be looking for entities that support timestampable each time an entity is inserted or updated. How do we make our VinylMix
support timestampable? The easiest way (and the way I like to do it) is via a trait.
At the top of the class, say use TimestampableEntity
.
... lines 1 - 7 | |
use Gedmo\Timestampable\Traits\TimestampableEntity; | |
... lines 9 - 10 | |
class VinylMix | |
{ | |
use TimestampableEntity; | |
... lines 14 - 124 | |
} |
That's it. We're done! Lunch break!
To understand this black magic, hold "cmd" or "ctrl" and click into TimestampableEntity
. This adds two properties: createdAt
and updatedAt
. And these are just normal fields, like the createdAt
that we had before. It also has getter and setter methods down here, just like we have in our entity.
The magic is this #[Gedmo\Timestampable()]
attribute. This says that:
this property should be set
on:
'update'
and
this property should be set
on: 'create'
.
Thanks to this trait, we get all of this for free! And... we no longer need our createdAt
property... because it already lives in the trait. So delete the property... and the constructor... and down here, remove the getter and setter methods. Cleansing!
The trait has a createdAt
property like we had before, but it also adds an updatedAt
field. And so, we need to create a new migration for that. You know the drill. At your terminal, run:
symfony console make:migration
Then... let's go check that file... just to make sure it looks like we expect. Let's see here... yup! We've got ALTER TABLE vinyl_mix ADD updated_at
. And apparently the created_at
column will be a little bit different than we had before.
... lines 1 - 12 | |
final class Version20220718170826 extends AbstractMigration | |
{ | |
... lines 15 - 19 | |
public function up(Schema $schema): void | |
{ | |
// this up() migration is auto-generated, please modify it to your needs | |
$this->addSql('ALTER TABLE vinyl_mix ADD updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL'); | |
$this->addSql('ALTER TABLE vinyl_mix ALTER created_at TYPE TIMESTAMP(0) WITHOUT TIME ZONE'); | |
$this->addSql('ALTER TABLE vinyl_mix ALTER created_at DROP DEFAULT'); | |
$this->addSql('COMMENT ON COLUMN vinyl_mix.created_at IS NULL'); | |
} | |
... lines 28 - 37 | |
} |
Okay, let's go run that:
symfony console doctrine:migrations:migrate
And... it fails!
[...] column "updated_at" of relation "vinyl_mix" contains null values
.
This is a Not null violation
... which makes sense. Our database already has a bunch of records in it... so when we try to add a new updated_at
column that doesn't allow null values... it freaks out.
If the current state of our database were already on production, we would need to tweak this migration to give the new column a default value for those existing records. Then we could change it back to not allowing null. To learn more about handling failed migrations, check out a chapter on our Symfony 5 Doctrine tutorial.
But since we do not have a production database yet that contains viny_mix
rows, we can take a shortcut: drop the database and start over with zero rows. To do that, run
symfony console doctrine:database:drop --force
to completely drop our database. And recreate it with
symfony console doctrine:database:create
At this point, we have an empty database with no tables - even the migrations table is gone. So we can re-run all of our migrations from the very beginning. Do it:
symfony console doctrine:migrations:migrate
Sweet! Three migrations were executed: all successfully.
Back over on our site, if we go to "Browse Mixes", it's empty... because we cleared our database. So let's go to /mix/new
to create mix ID 1... then refresh a few more times. Now head to /mix/7
... and upvote that, which will update that VinylMix
.
Ok! Let's see if timestampable worked! Check the database by running:
symfony console doctrine:query:sql 'SELECT * FROM vinyl_mix WHERE id = 7'
And... awesome! The created_at
is set and then the updated_at
is set to just a few seconds later when we upvoted the mix. It works. We can now easily add timestampable
to any new entity in the future, just by adding that trait.
Next: let's leverage another behavior: sluggable. This will let us create fancier URLs by automatically saving a URL-safe version of the title to a new property.
Hey guys!
No error? That's even better ;) Well, most probably you have a fresher Doctrine migrations package installed, or you have a fresher MariaDB server version, or even both :) I see your app was able to generate a single query migration, i.e. it's was optimized in comparison to the migration in the video. In other words, migrations might be slightly different because of different versions. So, as long as this migration works for you - that's great, you can ignore hunting about that error ;)
Cheers!
Same for me... My project ist connected to a local MariaDB, but this should not have any effect?!
Hey Remy,
I replied with my thoughts about this difference here: https://symfonycasts.com/screencast/symfony-doctrine/timestampable#comment-28049
Cheers!
If someone wants to migrate the newly generated migration without dropping the whole database, add DEFAULT CURRENT_TIMESTAMP(0)::TIMESTAMP WITHOUT TIME ZONE
in your updated_at
column's definition after NOT NULL
and running the migration should work fine.
Hey Ssi-anik,
Thank you for the tip! I personally didn't try this but I'll believe you that it works :)
Cheers!
Hi!
Is there a way to update the updateAt timestamp of a parent entity when only a collection of child entities is modified and not the parent entity itself ? The preUpdate Event seems not to be fired :-(
Thanks for your answer
Cyril
Finally I had to create my own trait to set the updatedAt value with PreFlush LifecycleCallback in the parent entity. It fires well the preUpdate Event but even when no change was made at all… If someone has another idea, it will be appreciated. Thanks !
hey @Cyril
It's pretty hard to get some advice without seeing how listener was implemented IIRC, you can check if there were some changes on entity, there is method on event argument to check the change set
Cheers!
Hi,
Here is my solution with an EventSubscriber. Maybe it could help someone else as it works for me.
//src/EventListener/TimestampCollectionParentEntitySubscriber.php
<?php
namespace App\EventListener;
use Doctrine\ORM\Events;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\Event\LifecycleEventArgs;
use Doctrine\Bundle\DoctrineBundle\EventSubscriber\EventSubscriberInterface;
class TimestampCollectionParentEntitySubscriber implements EventSubscriberInterface
{
public function __construct(private EntityManagerInterface $em) {
}
public function getSubscribedEvents(): array
{
//don't use "pre" events as it creates an infinite loop...
return [
Events::postPersist,
Events::postRemove,
Events::postUpdate,
];
}
public function postPersist(LifecycleEventArgs $args): void
{
$this->_timestampParentEntity($args);
}
public function postUpdate(LifecycleEventArgs $args): void
{
$this->_timestampParentEntity($args);
}
public function postRemove(LifecycleEventArgs $args): void
{
$this->_timestampParentEntity($args);
}
private function _timestampParentEntity($args) {
// During a Doctrine Event, the EntityManager gives us this array :
// em->getUnitOfWork()->getIdentityMap() which looks like
// [
// 'classname1' => [id1 => entity1],
// 'classname2' => [id2 => entity2]
// ]
// and its first element (entity1) is the highest entity in the form
// => this is the entity we want to timestamp
$identityMap = $this->em->getUnitOfWork()->getIdentityMap();
if($identityMap !== []) {
foreach(array_slice($identityMap, 0, 1, true) as $parentClassname => $parentArray);
foreach(array_slice($parentArray, 0, 1, true) as $parentId => $parent);
if($args->getObject() !== $parent && method_exists($parent, 'setUpdatedAt')) {
//here, the Event does NOT concern the parent itself BUT necessarily a child collection inside the parent => let's timestamp the parent!
$parent->setUpdatedAt(new \DateTime());
//then flush
$this->em->flush();
}
}
}
}
Honestly I'm not sure about your way, I think it can be done with less code and without several foreach
blocks, as I understand the collection is some sort of One-To-Many relation, so you can just make a doctrine listener and create some sort of ParentTimeStampableInterface
and check if persisted/updated/deleted entity is instance of this interface then simple getParent()
method to get parent entity and make your changes
or maybe I'm missing something =)
Cheers.
Of course there are other ways (array_values, for example) to get the parent entity which is a bit deeply wrapped in $identityMap which is an associative array.
But anyways, I don't understand your approach as I'm not very familiar with interfaces :-(
If you ever have time to spend in it, a small example of code will be appreciated!
Thanks
Hi there,
I was trying to avoid droping the database and played around a bit. There seems to be no way to rollback a migration.
I ended up dropping my database and start from scratch... that is NO solution on production machines. There are functions (up and down) filled automaticaly with code but
how can I tell doctrine to rollback the last migration? i.e. to use the down method of the actual migration?
I tried things like php app/console doctrine:migrations:execute YYYYMMDDHHMMSS --down
but that did not work. I got lots of errormessages like this
`In ExceptionConverter.php line 87:
An exception occurred while executing a query: SQLSTATE[42P06]: Duplicate schema: 7 ERROR: schema "public" already exists`
Hey @Georg-L!
Yes, when things go wrong with migrations, life gets tricky :p. And part of that can't be avoided: if we get surprised by a failing migration when deploying, it means something unexpected happened. Then, to make matters worse, fixing it may not be as simple as running the "down" method. For example, suppose you have a migration that executes 3 SQL statements. When you run it, the 1st is successful, but the 2nd fails (and so the 3rd also didn't run). If you ran the down()
method, it will likely also fail because usually those methods try to do up()
in reverse. So, for example, if the 3rd statement in up was a CREATE TABLE foo
, then the 1st statement in down()
will be DROP TABLE foo
... which will fail, because foo
was never created.
So, as you can see - a big mess when things fail! But sometimes running down WILL help. And you DID use the correct command. My guess is that it's failing due to the reason I described above.
When this happens to me (it's rare, but bad things DO happen), I debug it by hand. There's simply no automatic way to know exactly how a migration failed (did none of the statements run successfully? Or some of them?). In this situation, doctrine:schema:update
can be your friend:
php bin/console doctrine:schema:update --dump-sql
That'll show you what is "missing" from your production database. It might be enough to execute those:
php bin/console doctrine:schema:update --force
Or, more likely, I may copy those commands and "adapt" them quickly, and run them manually. For example, suppose, like in this video, I'm missing a new column that does NOT allow null... but that failed because of a not null violation. To fix this, I would copy the SQL statement from doctrine:schema:update
, change it to "YES" allow NULL temporarily, then run that. At that point, your site will probably start working again, and you can figure out your next steps. Probably you'll run some SQL to give all of the existing records some value for the column, and THEN run another SQL query to change the column from "yes" allows null to "NOT" allow null. Finally, after fixing things by hand, you may need to tell Doctrine manually that the migration that failed "did run" (since you have basically completed that migration manually) so that it won't try to run it again on the next deploy. You can do that with the doctrine:migrations:version
command, followed by the version.
Phew! Let me know if this helps - it is always an ugly situation when this happens!
Cheers!
Hey Rayn,
thank you so much for your answer. This is really very helpful. Yeah, failing migrations ar ugly, brrrr. I have some experience with phinx. I can tell you, that this can also be messed up, if you try hard enough ;)
So thanks to you, I have a toolset to handle these situations.
See U.
btw.
I love this course. You did a great job. Thank you.
By adding that trait, are you not effectively coupling your domain to the infrastructure (beyond the necessary and forgivable attributes/annotations for mapping), which should be what you'd want to avoid doing?
Hey Michael,
Hm, maybe? That trait gives you some ready-to-use functionality. You can do it yourself, with createdAt it's pretty easy, you can set it in the constructor. With the updatedAt - a bit more complex, you need to add a listener to track when the entity is actually updated. So, at least it save your time.
Cheers!
Hello,
For some reason, I get an error while installing this bundle the error:
[Semantical Error] The class "Symfony\Contracts\Service\Attribute\Required" is not annotated with @Annotation.
Are you sure this class can be used as annotation?
If so, then you need to add @Annotation to the class doc comment of "Symfony\Contracts\Service\Attribute\Required".
If it is indeed no annotation, then you need to add @IgnoreAnnotation("required") to the class doc comment of method Symfony\Bundle\FrameworkBundle\Controller\AbstractController::setContainer() in {"path":"..\/src\/Controller\/","namespace":"App\Controller"} (which is being imported from "/Users/macbookpro/Documents/symfonycasts/mixed_vinyl/config/routes.yaml"). Make sure there is a loader supporting the "attribute" type.
any help please!!
Hey JnahDev,
Did you download the course code, or are you following this tutorial on your own? It seems like your project is expecting "annotations" but in Symfony 6 those were removed and you should use PHP attributes
No I'm following the tutorial on my own, maybe I need to download the source code and continue other chapters.
Thank you MolloKhan.
yes, that's how we recommend following our tutorials, although you can do it on your own, but of course, you'll find problems like this along the way
I fixed this bug by adding annotations.yaml in config/routes, but I don't understand why this problem occurs.
controllers:
resource: ../../src/Controller/
type: annotation
kernel:
resource: ../../src/Kernel.php
type: annotation
After I installed Doctrine bundle I encountered this bug.
Hi I really like the automatic CreatedAt and UpdatedAt feature. I am wondering how do I change the column name that is created to store these, I'm thinking I would need to edit the file below and add the Doctrine column name property to the annotation for the protected $createdAt property?
\vendor\gedmo\doctrine-extensions\src\Timestampable\Traits\TimestampableEntity.php
Is there a more "reusable" way, that doesn't modify the file from the package?
Thank you
Mark
Hey Mark,
I'm afraid that's not possible if you use the entity trait. For that purpose, you'll have to add each property to your entity like this
#[Gedmo\Timestampable(on: 'create')]
#[ORM\Column(name: 'table_name', type: 'datetime')]
private ?\DateTimeInterface $createdAt = null;
I hope it helps. Cheers!
Hey!
After installing stof/docrtine-extensions-bundle
Symfony is throwing this error: Call to undefined method Doctrine\Common\Annotations\AnnotationRegistry::registerLoader()
. My version of Symfony is 6.2.1.
Hi, there seems to be a little typo : Instead of "get status" you meant "git status", correct?
Nice catch! Thank you TS
By the way, in case you're interested in helping a bit further. We have a GitHub link on each chapter script where you can submit change requests
Cheers!
// composer.json
{
"require": {
"php": ">=8.1",
"ext-ctype": "*",
"ext-iconv": "*",
"babdev/pagerfanta-bundle": "^3.7", // v3.7.0
"doctrine/doctrine-bundle": "^2.7", // 2.7.0
"doctrine/doctrine-migrations-bundle": "^3.2", // 3.2.2
"doctrine/orm": "^2.12", // 2.12.3
"knplabs/knp-time-bundle": "^1.18", // v1.19.0
"pagerfanta/doctrine-orm-adapter": "^3.6", // v3.6.1
"pagerfanta/twig": "^3.6", // v3.6.1
"sensio/framework-extra-bundle": "^6.2", // v6.2.6
"stof/doctrine-extensions-bundle": "^1.7", // v1.7.0
"symfony/asset": "6.1.*", // v6.1.0
"symfony/console": "6.1.*", // v6.1.2
"symfony/dotenv": "6.1.*", // v6.1.0
"symfony/flex": "^2", // v2.2.2
"symfony/framework-bundle": "6.1.*", // v6.1.2
"symfony/http-client": "6.1.*", // v6.1.2
"symfony/monolog-bundle": "^3.0", // v3.8.0
"symfony/proxy-manager-bridge": "6.1.*", // v6.1.0
"symfony/runtime": "6.1.*", // v6.1.1
"symfony/twig-bundle": "6.1.*", // v6.1.1
"symfony/ux-turbo": "^2.0", // v2.3.0
"symfony/webpack-encore-bundle": "^1.13", // v1.15.1
"symfony/yaml": "6.1.*", // v6.1.2
"twig/extra-bundle": "^2.12|^3.0", // v3.4.0
"twig/twig": "^2.12|^3.0" // v3.4.1
},
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.2
"symfony/debug-bundle": "6.1.*", // v6.1.0
"symfony/maker-bundle": "^1.41", // v1.44.0
"symfony/stopwatch": "6.1.*", // v6.1.0
"symfony/web-profiler-bundle": "6.1.*", // v6.1.2
"zenstruck/foundry": "^1.21" // v1.21.0
}
}
Hello,
For some reason, this is what I get in the version file
And I do not get any error message as mentioned in the exercise.
Best regards
Benoit Lorant