Today we are going to analyze a very common situation if you use Symfony framework: let’s talk about collection of objects, forms used to handle them and “collection swapping” (a.k.a. changing owner onto owning side of a collection relationship in doctrine).
Scenario
What we get here is a trivial situation: let’s say a client hired us to write the next super cool events booking application. So a user – that must be registered into the system – can buy its tickets through this app. Nothing easier!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
<?php namespace OurVendor\OurBundle\Entity; use Doctrine\ORM\Mapping as ORM; class User { // some properties here /*** * @ORM\OneToMany(targetEntity="Ticket", cascade={"persist"}, mappedBy="user") */ private $tickets; // getters and setters here } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
<?php namespace OurVendor\OurBundle\Entity; use Doctrine\ORM\Mapping as ORM; class Ticket { // some properties here /*** * @ORM\ManyToOne(targetEntity="User", inversedBy="tickets") */ private $user; // getters and setters here } |
Now, our client comes with an idea
Samuele, why don’t we add a killing feature where a super admin can login and handle tickets for a user that needs to be helped?
After this confusing sentence we come up with this reply
What’s the meaning of “handle” and “needs to be helped”?
We all know that is very difficult to deal with clients and after a sentence-reply-tennis-game – that will be omitted, of course! – we end up with these definitions:
- Handle means that a super admin can buy and refund tickets from a recap form (keep this in mind) on behalf of a user
- Needs to be helped means that a user can buy tickets and have a refund as well by email or by phone
By introducing this “killing feature” (wow) we need to build a form. This form will be based onto user and will show, among other things, a collection of tickets (with adding/deleting tickets capability).
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
<?php namespace OurVendor\OurBundle\Entity; use OurVendor\OurBundle\Entity\User; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\CollectionType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; class UserType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array $option) { // other fields here; not interesting $builder->add('tickets', CollectionType::class, [ 'entry_type' => TicketType::class, // not interesting; omitted 'allow_add' => true, 'allow_delete' => true, 'by_reference' => false, ]); } public function configureOptions(OptionsResolver $resolver) { $resolver->setDefaults([ 'data_class' => User::class, ]); } } |
Cool! We have our form. It’s important to note that TicketType
will not be reported as it’s not interesting for this scenario. UserController
will be not reported as well: it takes the Request
, binds it to the form, performs isValid()
control and writes e onto db. Furthermore we set by_reference
to false
to be sure that User
‘s setters will be called for the collection.
After we have placed some client-side magic (to add and remove tickets) the application fulfills starting requirements.
As soon as this form is used to delete a ticket from a user collection, software does not work as expected! As a matter of fact collection’s element are not removed!
Here you can easily figure out why this is happening.
When removing objects in this way, you may need to do a little bit more work to ensure that the relationship between the
Task
(ed:User
) and the removedTag
(ed:Ticket
) is properly removed.In Doctrine, you have two sides of the relationship: the owning side and the inverse side. Normally in this case you’ll have a many-to-many relationship and the deleted tags will disappear and persist correctly (adding new tags also works effortlessly).
But if you have a one-to-many relationship (ed: that’s our situation) or a many-to-many relationship with a
mappedBy
on the Task entity (meaning Task is the “inverse” side), you’ll need to do more work for the removed tags to persist correctly.
In this case, you can modify the controller to remove the relationship on the removed ticket. This assumes that you have some editAction
which is handling the “update” of your User:
Wait a minute … why should I manually do this
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
<?php namespace OurVendor\OurBundle\Controller; use Doctrine\Common\Collections\ArrayCollection; use OurVendor\OurBundle\Entity\User; use Symfony\Bundle\FrameworkBundle\Controller\Controller; use Symfony\Component\HttpFoundation\Request; class UserController extends Controller { //some not so interesting actions here // route definition omitted; take advantage of ParamConverter public function editAction(Request $request, User $user) { $em = $this->getDoctrine()->getManager(); $originalTickets = new ArrayCollection(); foreach ($user->getTickets() as $ticket) { $originalTickets->add($ticket); } $form = $this->createForm(UserType::class, $user); $form->handleRequest($request); if ($form->isValid()) { foreach ($originalTickets as $ticket) { if (false === $user->getTickets()->contains($ticket)) { $em->remove($ticket); } } } $em->persist($user); $em->flush(); } } |
when I can go like …
#2 poison
this
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
<?php namespace OurVendor\OurBundle\Entity; use Doctrine\ORM\Mapping as ORM; class User { // some properties here /*** * @ORM\OneToMany(targetEntity="Ticket", mappedBy="user", cascade={"persist"}, orphanRemoval=true) */ private $tickets; // getters and setters here } |
(we added orphanRemoval
to the collection)
without touching a line of code into controllers? Cool, isn’t it?
Yeah, a sort of. Because now we need to add an extra feature to admin dashboard as, if a user wants to unsubscribe from our site, it’s mandatory for him to give away all tickets he purchased to a user (by specifying its username in a form that will not be reported here).
Ok, so we come up with this solution
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
<?php namespace OurVendor\OurBundle\Controller; use Doctrine\Common\Collections\ArrayCollection; use OurVendor\OurBundle\Entity\User; use Symfony\Bundle\FrameworkBundle\Controller\Controller; use Symfony\Component\HttpFoundation\Request; class UserController extends Controller { //some not so interesting actions here // route definition omitted; take advantage of ParamConverter public function changeTicketOwnerAndDeleteUserAction(User $yieldingUser, User $beneficiaryUser) { foreach ($yieldingUser->getTickets() as $ticket) { $ticket->setUser($beneficiaryUser); } $em = $this->getDoctrine()->getEntityManager(); $em->remove($yieldingUser); // this is important for example comprehension $em()->flush(); // render a template w/ flash message and so on } } |
What do you expect from this snippet of code?
If the answer is something like
Tickets should now be related to
$beneficiaryUser
Well, you’re just wrong!
The reason behind this – at first glance – strange behavior is that
When using the
orphanRemoval=true
option Doctrine makes the assumption that the entities are privately owned and will NOT be reused by other entities. If you neglect this assumption your entities will get deleted by Doctrine even if you assigned the orphaned entity to another one.
BINGO!
Let me say that use cascade={"remove"}
is not a solution at all for our situation: when you remove an entity from a collection you, probably, don’t want to remove the collection holder as well (so you can return to #1 poison solution if you’re going to use cascading operation).
What really happens here is that, since we are removing a user, we are also removing all its tickets, despite of the fact we are assigning them to another user.
#3 poison
Ok, so we have a brilliant idea! Just remove those tickets from $yieldingUser
before assign them to beneficiaryUser
and all should work!
So we slightly modify previous code …
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
<?php namespace OurVendor\OurBundle\Controller; use Doctrine\Common\Collections\ArrayCollection; use OurVendor\OurBundle\Entity\User; use Symfony\Bundle\FrameworkBundle\Controller\Controller; use Symfony\Component\HttpFoundation\Request; class UserController extends Controller { //some not so interesting actions here // route definition omitted; take advantage of ParamConverter public function changeTicketOwnerAndDeleteUserAction(User $yieldingUser, User $beneficiaryUser) { foreach ($yieldingUser->getTickets() as $ticket) { $yeldingUser->removeTicket($ticket); // !!! $ticket->setUser($beneficiaryUser); } $em = $this->getDoctrine()->getEntityManager(); $em->remove($yieldingUser); // this is important for example comprehension $em()->flush(); // render a template w/ flash message and so on } } |
We are close to make it works but not yet: as a matter of fact, tickets are still removed!
The solution here is to modify setUser
function onto Ticket
entity as follows (we do not show as it was originally but it looked like a setter without any logic)
1 2 3 4 5 6 7 8 9 10 11 |
public function setUser(User $user) { $this->user = $user; // OLD PART // BRAND NEW LOGIC if (!$user->getTickets()->contains($this)) { $user->addTicket($this); } return $this; } |
And this is going to work (as long as you set cascade={"persist"}
into User
of course!)
BUT …
If you expect this to keep tickets ids … well, you are wrong!
In fact what happens here is that doctrine will remove old instances of ticket
from original collection and it will re-create them into new one.
You can understand yourself that, if you have a foreign key somewhere, this could drive you straight into big troubles!
RECAP
So, you can act knowingly now that you know this three situations and pick what fits you the most!
With #1 poison (standard scenario) you have to write code into controller (or, better, into a dedicated manager) just to handle correct persistence between old collection values and new ones.
With #2 poison (orphanRemoval
) you do not need to write any line of code BUT you simply can’t assign an element of a collection to another holder if you set orphanRemoval
With #3 poison (orphanRemoval
+ custom logic) you can avoid writing extra code logic but, if you need to switch collection’s element holder, you need to implement – again – some extra logic and you are going to loose original collection’s element ids.
So … pick your poison now!
+1 on using the term poison.
Doctrine 2 ORM works great on a small number of simple use cases but it is nonsense like this that made me go back to just plain sql queries and simple object factories. I would rather write a few extra lines of code than try to maintain the options given above.