Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Doctrine Extensions: Timestampable

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.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

I 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.

Installing stof/doctrine-extensions-bundle

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.

Enabling Timestampable

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
default_locale: en_US
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'


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!

Adding the Migration

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

When Migrations Fail

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.

Leave a comment!

Login or Register to join the conversation
Benoit-L Avatar
Benoit-L Avatar Benoit-L | posted 2 months ago | edited


For some reason, this is what I get in the version file

    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 DATETIME NOT NULL, CHANGE created_at created_at DATETIME NOT NULL');

    public function down(Schema $schema): void
        // this down() migration is auto-generated, please modify it to your needs
        $this->addSql('ALTER TABLE vinyl_mix DROP updated_at, CHANGE created_at created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\'');

And I do not get any error message as mentioned in the exercise.

Best regards

Benoit Lorant

1 Reply

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 ;)


1 Reply
remy Avatar

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


Benoit-L Avatar

It’s also a Maria DB on my side.


ssi-anik Avatar
ssi-anik Avatar ssi-anik | posted 3 months ago

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.

1 Reply

Hey Ssi-anik,

Thank you for the tip! I personally didn't try this but I'll believe you that it works :)


1 Reply
Markchicobaby Avatar
Markchicobaby Avatar Markchicobaby | posted 11 days ago

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?

Is there a more "reusable" way, that doesn't modify the file from the package?

Thank you


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!

Camyl Avatar


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.

TS Avatar

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


Cat in space

"Houston: no signs of life"
Start the conversation!

What PHP libraries does this tutorial use?

// 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