Cascade Persistence with Doctrine 2



Persisting your data manually with Doctrine may seem very simple at first glance. But in a complex application it can quickly become too much to manage. In this post I’ll introduce the notion of cascading persistence, how to use it, and what are the little traps to avoid.

OneToMany

Let’s imagine the following two entities:

/**
 * Author
 *
 * @ORM\Table(name="author")
 * @ORM\Entity
 */
class Author
{
    /**
     * @var integer
     *
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    protected $id;

    /**
     * @ORM\OneToMany(targetEntity="Book", mappedBy="author")
     */
    protected $books;

    public function __construct()
    {
        $this->books = new \Doctrine\Common\Collections\ArrayCollection;
    }

    public function addBook(Book $book)
    {
        $this->books[] = $book;
        $book->setAuthor($this);
    }
}

/**
 * Book
 *
 * @ORM\Table(name="book")
 * @ORM\Entity
 */
class Book
{
    /**
     * @var integer
     *
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    protected $id;

    /**
     * @ORM\ManyToOne(targetEntity="Author", inversedBy="books")
     * @ORM\JoinColumn(nullable=false)
     */
    protected $author;

    public function setAuthor(Author $author)
    {
        $this->author = $author;
    }
}

At the moment an author can write one or more books. A book has only one author. So we have a OneToMany relationship between Author and Book.

Let’s try to create an author and associate a book with it.

$author = new Author;
$book = new Book;
$author->addBook($book);
$manager->persist($author);
$manager->flush();

You should have an error of this type there:

[Doctrine\ORM\ORMInvalidArgumentException]
  A new entity was found through the relationship 'Author#books' that was not configured to cascade persist operations for
  entity: Book@0000000026085418000000011a266ab5. To solve this issue: Either explicitly call EntityManager#persist() on thi
  s unknown entity or configure cascade persist  this association in the mapping for example @ManyToOne(..,cascade={"persist"}). If you cannot find out which entity causes the problem implement 'Book#__toString()' to get a clue.

This is quite normal, we have asked to persist Author but at no time can Doctrine know that Book should be persisted as well.

To solve this problem, Doctrine offers us two solutions.

1. Manually persist Book entity

Let’s add the following line before persisting Author:

$manager->persist($book);

It’s fine, but it would be better if we could avoid writing this extra line. So we will configure our relationship to tell Doctrine to automatically persist Book.

2. Using cascading persistence

In the error message, Doctrine suggests to configure the relationship with cascade={"persist"}. Even if the configuration solution may seem “sexy”, it is still a bit tricky. Indeed you have to be careful which object is persisted first. In our case it is Author.

$manager->persist($author);

So we have to add the configuration in the Author class:

/**
 * @ORM\OneToMany(targetEntity="Book", mappedBy="author", cascade={"persist"})
 */
protected $books;

ManyToMany

Let’s go a little further and evolve our One To Many relationship. We will assume that a book can have several authors.

This gives us the following configuration:

/**
 * Author
 *
 * @ORM\Table(name="author")
 * @ORM\Entity
 */
class Author
{
    // ...

    /**
     * @ORM\ManyToMany(targetEntity="Book", cascade={"persist"})
     */
    protected $books;

    public function __construct()
    {
        $this->books = new \Doctrine\Common\Collections\ArrayCollection;
    }

    public function addBook(Book $book)
    {
        $this->books[] = $book;
    }
}

/**
 * Book
 *
 * @ORM\Table(name="book")
 * @ORM\Entity
 */
class Book
{
    // ...
}

We are in a unidirectional configuration to simplify the scheme but we could very well make it bidirectional by adding the relationship in Book.

Let’s add a second author and test:

$author = new Author;
$author2 = new Author;
$book = new Book;
$author->addBook($book);
$author2->addBook($book);
$manager->persist($author);
$manager->persist($author2);
$manager->flush();

A new table author_book has appeared. It contains two rows and shows us that we have two authors for the same book.

OneToMany - ManyToOne

Let’s evolve our relationship once again. In addition to knowing the authors of a book, we would like to know the date when the author started writing about the book. To do this we have to modify our relationship table to add a new field. Because of this our relationship table will become a separate entity, this is the only way to do it.

/**
 * Author
 *
 * @ORM\Table(name="author")
 * @ORM\Entity
 */
class Author
{
    // ...

    /**
     * @ORM\OneToMany(targetEntity="AuthorBook", mappedBy="author", cascade={"persist"})
     */
    protected $authorBooks;

    public function __construct()
    {
        $this->authorBooks = new \Doctrine\Common\Collections\ArrayCollection;
    }

    // ...

    public function addAuthorBook(AuthorBook $authorBook)
    {
        $authorBook->setAuthor($this);
        $this->authorBooks[] = $authorBook;
    }
}

/**
 * AuthorBook
 *
 * @ORM\Table(name="author_book", uniqueConstraints={@ORM\UniqueConstraint(name="author_book_idx", columns={"author_id", "book_id"})})
 * @ORM\Entity
 */
class AuthorBook
{
    /**
     * @ORM\ManyToOne(targetEntity="Author", inversedBy="authorBooks")
     * @ORM\Id
     */
    protected $author;

    /**
     * @ORM\ManyToOne(targetEntity="Book", inversedBy="bookAuthors")
     * @ORM\Id
     */
    protected $book;

    /**
     * @ORM\Column(type="date")
     */
    protected $startedAt;

    public function __construct()
    {
        $this->startedAt = new \DateTime;
    }

    public function setAuthor(Author $author)
    {
        $this->author = $author;
    }

    public function setBook(Book $book)
    {
        $this->book = $book;
    }
}

/**
 * Book
 *
 * @ORM\Table(name="book")
 * @ORM\Entity
 */
class Book
{
    // ...

    /**
     * @ORM\OneToMany(targetEntity="AuthorBook", mappedBy="book")
     */
    protected $bookAuthors;

    public function __construct()
    {
        $this->bookAuthors = new \Doctrine\Common\Collections\ArrayCollection;
    }

    // ...

    public function addBookAuthor(AuthorBook $bookAuthor)
    {
        $bookAuthor->setBook($this);
        $this->bookAuthors[] = $bookAuthor;
    }
}

Let’s try to populate our database.

$author = new Author;
$author2 = new Author;

$book = new Book;

$authorBook = new AuthorBook;

$author->addAuthorBook($authorBook);
$book->addBookAuthor($authorBook);

$authorBook2 = new AuthorBook;

$author2->addAuthorBook($authorBook2);
$book->addBookAuthor($authorBook2);

$manager->persist($author);
$manager->persist($author2);
$manager->flush();

Unfortunately Doctrine does not look happy:

[Doctrine\ORM\ORMException]
  Entity of type AuthorBook has identity through a foreign entity Author, however this entity has no identity itself. You have to call EntityManager#persist() on the related entity and make sure that an identifier was generated before trying to persist 'AuthorBook'. In case of Post Insert ID Generation (such as MySQL Auto-Increment or PostgreSQL SERIAL) this means you have to call EntityManager#flush() between both persist operations.

Here we go, this is exactly the problem I encountered that made me want to write this post. Yet we do have the right configuration thanks to cascade={"persist"}. So what’s the problem?

It’s pretty simple in the end. The answer comes from the fact that the primary key of my linking entity is composed of my two foreign keys Author and Book. Doctrine, via the cascade configuration, tries to persist the AuthorBook entity. To do so, it must generate a new primary key. Unfortunately author_id does not exist since Author has not been flushed yet, so its id is unknown to Doctrine.

As for the OneToMany relationship we can manually flush Author and Book before but this solution is not adequate in many situations.

Remember, a little earlier I said that our relationship table would become a separate entity. All we have to do is assign it an id!

So AuthorBook becomes:

/**
 * AuthorBook
 *
 * @ORM\Table(name="author_book", uniqueConstraints={@ORM\UniqueConstraint(name="author_book_idx", columns={"author_id", "book_id"})})
 * @ORM\Entity
 */
class AuthorBook
{
    /**
     * @var integer
     *
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    protected $id;

    /**
     * @ORM\ManyToOne(targetEntity="Author", inversedBy="authorBooks")
     */
    protected $author;

    /**
     * @ORM\ManyToOne(targetEntity="Book", inversedBy="bookAuthors", cascade={"persist"})
     */
    protected $book;

    /**
     * @ORM\Column(type="date")
     */
    protected $startedAt;

    // ...
}

Notice also the cascade={"persist"} on $book. And yes, by persisting Author, Doctrine will want to persist AuthorBook which in turn must persist Book.

Warning: In this last case we have a two-level persistence. Imagine if you had three, or even four levels. This is something that happens regularly. Managing multi-level persistence can be quite complex.

Conclusion

Implicit persistence is very powerful and above all very practical. It avoids code redundancy and frees the developer from an additional constraint. However, this persistence must be mastered. One can quickly get lost when the number of entities to be persisted increases.

You can also read the documentation.