Database referential integrity with Doctrine

altesack

Marat Latypov

Posted on May 7, 2024

Database referential integrity with Doctrine

Here could be extensive AI-generated introduction about Database referential integrity, but not today. Suppose you know what is it. The subject of this article is how you can deal with it using Doctrine.

Actually Doctrine can deal with it in several ways and sometimes it's not so obvious as we want.

Example #1

Suppose we have a blog, entities User and Post, and every post has some author which is one of users.

In the most simple case we create unidirectional 'many-to-one' association

#[ORM\Table(name: 'posts')]
class Post {
    ...
    #[ORM\ManyToOne(targetEntity: User::class)]
    #[ORM\JoinColumn(nullable: false)]
    private User $author;
    ...
}
Enter fullscreen mode Exit fullscreen mode

On MySQL we will get the post table with one foreign key. Everything is easy

create table posts
(
    ...
    author_id  int  not null,
    constraint FK_58A92E65F675F31B
        foreign key (author_id) references users (id)
);

create index IDX_58A92E65F675F31B
    on posts (author_id);
Enter fullscreen mode Exit fullscreen mode

So what we have now?

Now we have a constraint on DB level, which blocks any attemts to delete user which is linked as an author to some blog post

[23000][1451] 
Cannot delete or update a parent row: a foreign key constraint fails 
(`database`.`posts`, CONSTRAINT `FK_58A92E65F675F31B` FOREIGN KEY (`author_id`) REFERENCES `user` (`id`))
Enter fullscreen mode Exit fullscreen mode

There are no difference do you try to delete it directly in database or through your application. It's database constraint and it works this way.

Example #2

So now we can't delete a guy who has a post in our blog. But what if we must be able do it sometimes?

First of all we have to decide what to do with posts of deleted author? For example we could want to delete them too. I this case we can add onDelete option:

#[ORM\Table(name: 'posts')]
class Post {
    ...
    #[ORM\ManyToOne(targetEntity: User::class)]
    #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE' )]
    private User $author;
    ...
}
Enter fullscreen mode Exit fullscreen mode

In this case our foreign key will get on delete cascade

create table posts
(
    ...
    author_id    int          not null,
    constraint FK_58A92E65F675F31B
        foreign key (author_id) references users (id)
            on delete cascade
);

create index IDX_58A92E65F675F31B
    on posts (author_id);
Enter fullscreen mode Exit fullscreen mode

This means if we delete user all his posts will be removed too.

But remember, some posts can have comments. Do you want remove them too? All the comments to the posts written by this guy? In this case you probably should the same cascade trick with comments

One important thing here is that all this logic is done by DB server. Doctrine does nothing with it. From doctrine level everything you do is just about removing user.

Example #3

Let's think about application logic. Is it so bad if some post will not have author? If the only effect there will be no author on the page from some point of view it could be acceptable. So we can just clear author field in this case.

In this case we need to make it nullable first, and then change onDelete parameter

    #[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL' )]
    private ?User $author = null;
Enter fullscreen mode Exit fullscreen mode

In this case our field in database will look like this:

    constraint FK_58A92E65F675F31B
        foreign key (author_id) references users (id)
            on delete set null
Enter fullscreen mode Exit fullscreen mode

And this is enough.

Just want to note again, all these cases will be handled by DB server. Doctrine has nothing to do here

Example #4

Let's step back and now we want to remove posts with author. We want the same effect, but using Doctrine. Here we will need bidirectional association

So in Post entity we put this:

  #[ORM\ManyToOne(targetEntity: User::class, inversedBy: "posts")]
  #[ORM\JoinColumn(nullable: false)]
  private ?User $author = null;
Enter fullscreen mode Exit fullscreen mode

And in User entity this:

  #[ORM\OneToMany(targetEntity: Post::class, mappedBy: 'author', cascade: ['remove'])]
  private Collection $posts;
Enter fullscreen mode Exit fullscreen mode

Of course we'll have to add constructor, and getters/setters if you need. But in general this code will work!
It acts like onDelete: 'CASCADE'. The main difference is that now Doctrine does all the things.

Actually now Doctrine generates a bunch of sql-queries to delete user, something like this:

-- fetch post IDs for the author
SELECT post.id FROM posts as post WHERE post.author_id = AUTHOR_ID;
-- delete posts one by one
DELETE FROM posts WHERE id = POST_ID_1;
DELETE FROM posts WHERE id = POST_ID_2;
...
DELETE FROM posts WHERE id = POST_ID_N;
-- finally delete the author
DELETE FROM users WHERE users.id = AUTHOR_ID;
Enter fullscreen mode Exit fullscreen mode

Example #5

Absolutely the same effect will be using orphanRemoval: true

Just use it in User entity

  #[ORM\OneToMany(targetEntity: Post::class, mappedBy: 'author',  orphanRemoval: true)]
  private Collection $posts;
Enter fullscreen mode Exit fullscreen mode

Here are described the most common cases.

💖 💪 🙅 🚩
altesack
Marat Latypov

Posted on May 7, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related