Article

More on one-to-many/many-to-one associations in Doctrine 2

September 10th, 2013

In my previous blog I explained how to handle the mythical "join tables with extra columns" in Doctrine 2. If you haven't read it, please do so first.

It comes down to not creating a many-to-many association, but a one-to-many/many-to-one.

In this case of "people that have jobs at companies", the concept of working with these objects was as follows:

  • When we need a new job, we create it, set data, add it to a person and add it to a company.
  • When we need to remove a job, we remove it from the person (or company).

But in the real world we usually treat this process a bit differently: We draw up a contract in which we specify the information that's needed, including the person and company it relates to. In other words, we work directly with the job, not with the person or company it concerns (by adding a job to it). So the concept of working with these objects changes a bit:

  • When we need a new job, we create it and set data (including the person and company).
  • When we need to remove a job, we remove it ;)

This new process can be achieved with Doctrine 2 too, and is perhaps a "cleaner" way of doing it in this case.

The entities

Let's set up some entities again:

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

/**
 * @ORM\Entity
 * @ORM\Table(name="people")
 */
class Person
{
    /**
     * @ORM\Column(type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue
     */
    protected $id;

    /**
     * @ORM\Column(type="string")
     */
    protected $name;

    /**
     * @ORM\OneToMany(targetEntity="Job", mappedBy="person", cascade={"remove"})
     */
    protected $jobs;

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

    public function getId()
    {
        return $this->id;
    }

    public function getName()
    {
        return $this->name;
    }

    public function setName($name)
    {
        $this->name = $name;
        return $this;
    }

    public function getJobs()
    {
        return $this->jobs->toArray();
    }

    public function addJob(Job $job)
    {
        if (!$this->jobs->contains($job)) {
            $this->jobs->add($job);
        }

        return $this;
    }

    public function removeJob(Job $job)
    {
        if ($this->jobs->contains($job)) {
            $this->jobs->removeElement($job);
        }

        return $this;
    }

    public function getCompanies()
    {
        return array_map(
            function ($job) {
                return $job->getCompany();
            },
            $this->jobs->toArray()
        );
    }
}

/**
 * @ORM\Entity
 * @ORM\Table(name="jobs")
 */
class Job
{
    /**
     * @ORM\Column(type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue
     */
    protected $id;

    /**
     * @ORM\ManyToOne(targetEntity="Person", inversedBy="jobs")
     * @ORM\JoinColumn(name="person_id", referencedColumnName="id", nullable=FALSE)
     */
    protected $person;

    /**
     * @ORM\ManyToOne(targetEntity="Company", inversedBy="jobs")
     * @ORM\JoinColumn(name="company_id", referencedColumnName="id", nullable=FALSE)
     */
    protected $company;

    /**
     * @ORM\Column(type="date", name="started_on")
     */
    protected $startedOn;

    /**
     * @ORM\Column(type="integer", name="monthly_salary")
     */
    protected $monthlySalary;

    public function getId()
    {
        return $this->id;
    }

    public function getPerson()
    {
        return $this->person;
    }

    public function setPerson(Person $person = null)
    {
        if ($this->person !== null) {
            $this->person->removeJob($this);
        }

        if ($person !== null) {
            $person->addJob($this);
        }

        $this->person = $person;
        return $this;
    }

    public function getCompany()
    {
        return $this->company;
    }

    public function setCompany(Company $company = null)
    {
        if ($this->company !== null) {
            $this->company->removeJob($this);
        }

        if ($company !== null) {
            $company->addJob($this);
        }

        $this->company = $company;
        return $this;
    }

    public function getStartedOn()
    {
        return $this->startedOn;
    }

    public function setStartedOn(\DateTime $startedOn)
    {
        $this->startedOn = $startedOn;
        return $this;
    }

    public function getMonthlySalary()
    {
        return $this->monthlySalary;
    }

    public function setMonthlySalary($monthlySalary)
    {
        $this->monthlySalary = $monthlySalary;
        return $this;
    }
}

/**
 * @ORM\Entity
 * @ORM\Table(name="companies")
 */
class Company
{
    /**
     * @ORM\Column(type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue
     */
    protected $id;

    /**
     * @ORM\Column(type="string")
     */
    protected $name;

    /**
     * @ORM\OneToMany(targetEntity="Job", mappedBy="company", cascade={"remove"})
     */
    protected $jobs;

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

    public function getId()
    {
        return $this->id;
    }

    public function getName()
    {
        return $this->name;
    }

    public function setName($name)
    {
        $this->name = $name;
        return $this;
    }

    public function getJobs()
    {
        return $this->jobs->toArray();
    }

    public function addJob(Job $job)
    {
        if (!$this->jobs->contains($job)) {
            $this->jobs->add($job);
        }

        return $this;
    }

    public function removeJob(Job $job)
    {
        if ($this->jobs->contains($job)) {
            $this->jobs->removeElement($job);
        }

        return $this;
    }

    public function getPeople()
    {
        return array_map(
            function ($job) {
                return $job->getPerson();
            },
            $this->jobs->toArray()
        );
    }
}

Things to note:

  • The associations Person::$jobs and Company::$jobs no longer have cascade={"persist"} or orphanRemoval=TRUE. These are no longer required for this setup.
  • The cascade={"remove"} remains because we still want to remove all related jobs when a person or company is removed.
  • Job::setPerson() and Job::setCompany() now have some lines that will add itself to the person/company when it's set, and removes itself when NULL is set. This logic to keep both sides of the association in sync has been removed from Person and Company, because we are going to work "the other way around".

The work

First we create a person and company (again):

$person = new Person();
$person->setName('Jasper N. Brouwer');
$em->persist($person);

$company = new Company();
$company->setName('Future500 B.V.');
$em->persist($company);

$em->flush();

We can now add a new job:

$person = $em->find('TestPerson', 1);
$company = $em->find('TestCompany', 1);

$job = new TestJob();
$job->setPerson($person)
    ->setCompany($company)
    ->setStartedOn(new DateTime('01-10-2009'))
    ->setMonthlySalary(10000);
$em->persist($job);

$em->flush();

As you can see we don't do anything with the person and company, other then setting them on the job.

Again, this also works while creating a new person:

$company = $em->find('TestCompany', 1);

$person = new TestPerson();
$person->setName('Ramon de la Fuente');
$em->persist($person);

$job = new TestJob();
$job->setPerson($person)
    ->setCompany($company)
    ->setStartedOn(new DateTime('16-02-2006'))
    ->setMonthlySalary(10000);
$em->persist($job);

$em->flush();

And removing a job can now be done directly too:

$job = $em->find('TestJob', 1);
$em->remove($job);

$em->flush();

A small catch

When we remove a job, the job gets deleted from the database when $em->flush() is called. But in our object-graph nothing has changed. Job::$person and Job::$company still have a reference to the person and company, and the job still exists in Person::$jobs and Company::$jobs.

This is fine when the request ends after $em->flush(), the next request will fetch things from the database again, and the job will indeed be gone. But when more work is needed in the same request, it's best to update our objects immediately.

This can be done by implementing an event-listener that will pick up on Doctrine 2's preRemove event:


use Doctrine\ORM\Event\LifecycleEventArgs; class RemoveJobListener { public function preRemove(LifecycleEventArgs $args) { if ($args->getEntity() instanceof Job) { $job = $args->getEntity(); $job->setPerson(null); $job->setCompany(null); } } }

Register this listener in Doctrine 2's event-dispatcher and the following will happen:

As soon as $em->remove($job) is called, our event-listener will kick in and make sure Job::$person and Job::$company are set to NULL, and the job is removed from Person::$jobs and Company::$jobs. Then when $em->flush() is called the database will be updated. After that everything is nicely in sync.

Conclusion

We have set up the "people have jobs at companies" case in such a way that we can directly create and remove jobs. For this particular case this setup is more desirable than what's described in my previous blog. But there are other one-to-many/many-to-one cases where my previous blog makes more sense. So look carefully at the case your presented, and choose the setup wisely :)

Jasper N. Brouwer

Senior Software Developer