In this video we are going to look at a way to use Doctrine's Lifecycle events to help setup our Entities whenever they are loaded back from the database.

In this particular example we will cover a way to set a property on a File entity. We will inject a base URL that can be combined with the file name to produce a URI that an end user could access.

As a general precaution it would be a better idea to rename the file whenever uploading, keeping an $internalName and a $displayName perhaps. But that's outside the scope of this video.

The primary reason for doing this in the context of a File would be to offer a different base URL in app_dev than you might use in app_prod. This is the example we will work through in this video.

There may very well be better ways of doing this - and as ever, if you know of them then do please leave a comment below.

Setup

Our setup for this is that we have a BlogPost which can have many Files.

Th reasoning for the ManyToMany annotation is that a One-To-Many unidirectional relationship is done this way in Doctrine.

<?php

// /src/AppBundle/Entity/BlogPost.php

namespace AppBundle\Entity;

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

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

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

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

    /**
     * @ORM\ManyToMany(targetEntity="File")
     * @ORM\JoinTable(name="blog_post__file",
     *      joinColumns={@ORM\JoinColumn(name="blog_post_id", referencedColumnName="id")},
     *      inverseJoinColumns={@ORM\JoinColumn(name="file_id", referencedColumnName="id")}
     * )
     * @var Collection
     */
    protected $files;

    /**
     * BlogPost constructor.
     */
    public function __construct()
    {
        $this->files = new ArrayCollection();
    }

    /**
     * @return mixed
     */
    public function getId()
    {
        return $this->id;
    }

    // * snip *

    /**
     * @return Collection
     */
    public function getFiles()
    {
        return $this->files;
    }

    /**
     * @param File $file
     * @return BlogPost
     */
    public function addFile(File $file)
    {
        if ( ! $this->hasFile($file)) {
            $this->files->add($file);
        }

        return $this;
    }

    /**
     * @param File $file
     * @return BlogPost
     */
    public function removeFile(File $file)
    {
        if ($this->hasFile($file)) {
            $this->files->removeElement($file);
        }

        return $this;
    }

    /**
     * @param File $file
     * @return bool
     */
    public function hasFile(File $file)
    {
        return $this->files->contains($file);
    }
}

and the File entity:

<?php

// /src/AppBundle/Entity/File.php

namespace AppBundle\Entity;

use Doctrine\ORM\Mapping as ORM;

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

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

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

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

    /**
     * @var string
     */
    private $baseUrl;

    /**
     * @return mixed
     */
    public function getId()
    {
        return $this->id;
    }

    /**
     * @return mixed
     */
    public function getName()
    {
        return $this->name;
    }

    // * snip * 

    public function getUri()
    {
        return sprintf('%s/%s',
            $this->baseUrl,
            $this->getName()
        );
    }

    /**
     * @param string $baseUrl
     * @return $this
     */
    public function setBaseUrl(string $baseUrl)
    {
        $this->baseUrl = $baseUrl;

        return $this;
    }
}

I dislike this as we need to have a setter on our File entity. It certainly feels like a better solution to this problem must exist...

Anyway, even though we have a setter and a class property for baseUrl, we aren't actually persisting (think: saving) this information as part of the File entity. It can be considered to be a virtual property.

This is beneficial in some ways - our database is not polluted with this repetitive base URL, and also this means we can achieve our goal of different base URLs for dev and prod.

It sucks because if stuff can get changed (which is to say that someone accidentally calls the setter) then almost inevitably at some point in the future, it will.

Design discussion aside (on that point, at least), we can now go ahead and take a look at one potential solution to this problem, which is to use the postLoad Doctrine Lifecycle event to call the setBaseUrl method on our File entity, whenever it is pulled back from the database.

Doctrine postLoad Lifecycle Event Example

Firstly, we need to define the two different URL's we want as our base URL.

The way in which I am going to do this is by hardcoding them into config_prod.yml, and config_dev.yml. In reality, you should use a parameter from parameters.yml.

# /app/config/config_dev.yml

parameters:
    file_url: "http://dev.fake/path/to"


# /app/config/config_prod.yml

parameters:
    file_url: "https://some.otherpath.com/different"

With these two parameters sharing the same name, we can then inject the %file_url% parameter directly into the same event listener that we will use to listen for Doctrine's postLoad event. This ensures the right base URL is passed in, without directly tying ourselves to any particular environment.

Our listener could therefore be implemented as follows:

<?php

// /src/AppBundle/Event/Listener/FileEntityDoctrineEventListener.php

namespace AppBundle\Event\Listener;

use AppBundle\Entity\File;
use Doctrine\ORM\Event\LifecycleEventArgs;

class FileEntityDoctrineEventListener
{
    /**
     * @var string
     */
    private $baseUrl;

    /**
     * FileEntityDoctrineEventListener constructor.
     * @param string $baseUrl
     */
    public function __construct(string $baseUrl)
    {
        $this->baseUrl = $baseUrl;
    }

    public function postLoad(LifecycleEventArgs $eventArgs)
    {
        $file = $eventArgs->getEntity();

        if ( ! $file instanceof File) {
            return;
        }

        $file->setBaseUrl(
            $this->baseUrl
        );
    }
}

We would also need the service definition:

# /app/config/services.yml

services:
    crv.event.listener.file_entity_doctrine_event_listener:
        class: AppBundle\Event\Listener\FileEntityDoctrineEventListener
        arguments:
            - "%file_url%"
        tags:
            - { name: doctrine.event_listener, event: postLoad }

Ok, so cool. This works, but let's quickly review the postLoad method:

    public function postLoad(LifecycleEventArgs $eventArgs)
    {
        $file = $eventArgs->getEntity();

        if ( ! $file instanceof File) {
            return;
        }

        $file->setBaseUrl(
            $this->baseUrl
        );
    }

A downside to this approach is that this listener will be called every single time an entity is brought back from the database. This adds overhead. Not much in itself, but when added on to the substantial overhead already added by using a big framework, it isn't free either.

Therefore, the first thing we want to do is check if this entity is indeed an instance of File. If it ain't, then we bail fast.

However, if it is, then we want to setBaseUrl to whatever we passed in via the constructor. Nice.

Database Fixtures

As an aside here for the purposes of this video I have created a very basic file containing some Doctrine database fixtures.

As an example of how fixtures should be created, this is really not great. I have covered Doctrine Fixtures before in a previous series, so be sure to check that out if you like the look of these, and want to learn a little more about them.

That said, database fixtures come in all shapes and sizes. With Behat, for example, you create your own fixtures per feature.

Or there are even third party bundles that have the sole purpose of helping you create loads of fake, but real-looking data.

Easy Performance Gains

One thing demonstrated in this video is the use of the convenience methods that Doctrine provides such as find. Be aware that for the sake of developer convenience, you pay an exponential price in query performance.

It is better to write your own queries whenever possible, and it's really not that hard to learn how to do so.

Code For This Episode

Get the code for this episode.

Share This Episode

If you have found this video helpful, please consider sharing. I really appreciate it.


Episodes in this series

# Title Duration
1 How To Use Lifecycle Callbacks in Symfony 2 05:23
2 Going Further With Lifecycle Callbacks 05:29