Soft-delete in Symfony with Doctrine

25 April 2015, Rhodri Pugh

One of our applications, BindHQ, requires we keep a full auditable history of the data that passes through the system. The way we try to achieve this is with an append-only model, but the standard techniques and examples you’ll find when researching Symfony generally demonstrate update-in-place usage.

This means that new data overwrites old data, and specifically for this blog post that means deleting entities that could possibly be related from other parts of the system.

An Example

A simple example would involve having a customer entity with a number of agents working with them. These agents are our point of contact with the client, so we record a relation to the agent in any communication we have with them.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\Mapping as ORM;

/**
* @ORM\Entity()
*/

class Customer
{
/**
* @ORM\OneToMany(targetEntity="CustomerAgent",
* cascade={"persist", "remove"},
* orphanRemoval=true)
*/

private $agents;

public function __construct()
{
$this->agents = new ArrayCollection();
}
}

This model can then easily be used in a Symfony form, with entities added and deleted from the database as needed.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;

class CustomerType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder->add(
'agents',
'collection',
[
'type' => 'customer_agent_type',
'allow_add' => true,
'allow_delete' => true
]
);
}
}

(For simplicity I’m not going to detail the entire implementation, I’m going to have to assume if you’re reading this then you’re familiar with the territory)

The problem with this approach though is that when agents are removed from the form they’ll be deleted from the database (or the operation will fail if you’re using referential integrity and that entity is used elsewhere!). So how do we go about allowing the user to delete old agents, but keep all our data, simply.

And by simply here I mean by using our current tool-chain and not having to re-imagine our data model to use a dedicated append-only type store with view layers and all sorts… which might be needed at some point of course, but it also might not for now. I’m still a fan of what you can do with SQL ;)

No References

By default Doctrine/Symfony will not use any getters and setters that you’ve defined on your entity when adding and removing relations. These for example will not be touched…

<?php

class Customer
{
public function addAgent(CustomerAgent $agent)
{
$this->agents[] = $agent;

$agent->setCustomer($this);

return $this;
}

public function removeAgent(CustomerAgent $agent)
{
$this->agents->removeElement($agent);

return $this;
}
}

Our adder here sets the back-reference on the agent. To get these methods called we need to use the by_reference option on our form type.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?php

class CustomerType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder->add(
'agents',
'collection',
[
'type' => 'customer_agent_type',
'allow_add' => true,
'allow_delete' => true,
'by_reference' => false // call our methods please!
]
);
}
}

Now we have the option to hook into how our relations are added and removed.

Soft-delete

To implement our soft-delete we’re going to add a simple inactive flag to the agents, and then use the Doctrine Criteria class to filter agents on the customer.

1
2
3
4
5
6
7
8
9
10
11
12
<?php

class CustomerAgent
{
/**
* @ORM\Column(name="inactive",
* type="boolean",
* nullable=false,
* options={"default" = 0})
*/

private $inactive;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php

use Doctrine\Common\Collections\Criteria;

class Customer
{
public function getAgents()
{
$active = Criteria::expr()->eq('inactive', false);
$criteria = Criteria::create()->where($active);

return $this
->agents
->matching($criteria);
}
}

The final important detail is to tell Doctrine not to cascade deletions (as we’d like to keep our entities), and then instead of removing the agents from our collection we’re just going to mark them as inactive.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php

use DateTime;

class Customer
{
/**
* @ORM\OneToMany(targetEntity="CustomerAgent",
* cascade={"persist"})
*/

private $agents;

public function removeAgent(CustomerAgent $agent)
{
$agent
->setInactive(true)
// we can also record details of the deletion
->setDateDeleted(new DateTime());

return $this;
}
}

Conclusion

With this approach we can keep all our data and relations, even after the user has “deleted” them. It’s then up to us of course to add appropriate interfaces for potentially listing deleted agents and allowing the user to see when something references a deleted agent, etc…

As I mentioned earlier there are more complicated and fully-featured approaches to implementing soft deletion, but for a lot of cases I’m sure something like this simple change will suffice.