DDD using Doctrine 2: A case study

Tibo BeijenJune 27th, 2011

Nowadays developing web applications usually requires a flexible process due to changing business logic, shifting priorities or new insights. Besides choosing the right methodology this also requires designing the application in such a way that this flexibility can be achieved.

Domain Driven Design fits this process as it isolates business logic in the Domain layer and separates it from infrastructure and presentation layers. Questions like where or how to store data or what to build (website, mobile app, API) can be addressed separately.

Doctrine 2 provides PHP developers with a powerful tool to create a Domain layer that contains business logic that is easy to unit test and therefore easy to expand upon in iterations.

In this article I will show how to implement a specific case using Doctrine 2. Full code accompanying this article can be found on GitHub.

In this article

Case outline

The application developed is a time registration tool that allows users to enter per-day time sheets and submit them for approval. A manager will approve or disapprove the time sheet. The business requirement this iteration focuses on is the tracking of time sheet status history and only allowing specific status changes. In subsequent iterations features like registrations, tasks, projects and permissions will be implemented.

Description of entities


A User can login, has attributes and – interesting for this case – can have timesheets. A user’s email address can be changed at any time but should be unique as it serves as the login name.


A TimeSheet holds registrations of a single user, the registrant. Those registrations will be reviewed by a manager who will approve or disapprove them on a per-timesheet basis. The timesheet therefore can have the following statuses:

  • Open – The default status of every new timesheet
  • Submitted – The timesheet has been submitted for approval
  • Approved – The manager has approved the timesheet
  • Disapproved – The manager has disapproved the timesheet
  • Final – The timesheet’s registrations have been processed and therefore can never be changed. Examples of processing are periodic business reports or salary payment.

Status changes will be stored to allow a history of status changes to be shown. The most recent status change reflects the timesheet’s ‘current’ status.

Status changes are only allowed to be added in a specific order.


Each subsequent status of a timesheet is represented by a TimeSheetStatusChange. Properties of a status change are the status and the date the new status has been applied. Once stored, the status and date properties of a status change are not allowed to change to prevent ‘breaking’ the history of status changes a timesheet went through.

What is not included

As mentioned in the case outline, some obvious entities or properties are out of scope, such as the registrations themselves and the date the timesheet applies to. For the business requirements described previously they are not relevant so, in true Agile fashion, they will be implemented in subsequent iterations.

Implementing requirements using Doctrine 2

User should have an e-mail address that is unique

This is achieved by requiring an e-mail address in the constructor and by setting the column definition of the property to be unique.


class User
    // ...
     * @Column(type="string", length=128, unique=true)
     * @var string
    private $email;
    // ...

A TimeSheet should always belong to a user which, once specified, cannot be changed

This is enforced by requiring a User entity to be supplied in the constructor. As there’s no point in changing the registrant of an existing TimeSheet, the registrant can only be specified on creation. Therefore there is no setter for the registrant property.

(See next code example)

A new TimeSheet by default should have a status ‘open’

In the TimeSheet constructor, a new TimeSheetStatusChange is created and added to the status changes.


class TimeSheet
    // ...
     * Constructor requiring a registrant user instance
     * @param User $registrant
    public function __construct(User $registrant)
    	$this->registrant = $registrant;
    	$this->statusChanges = new ArrayCollection();
    	$this->addStatusChange(new TimeSheetStatusChange('open'));
    // no setRegistrant()
    // ...

One might think that adding a default status change to a new instance would result in a duplicate first status change when the persisted status changes are loaded while fetching the TimeSheet from the database. This is not the case as Doctrine 2 does not instantiate the constructor of entities. Read more on: Doctrine 2: Give me my constructor back

Status changes should only be allowed in specific order

This is achieved by validating each TimeSheetStatusChange that is added via the addStatusChange method. Only specific transitions are allowed.


class TimeSheet
    // ...
     * Performs status change validation logic
     * @param string $statusChange
     * @return boolean
    protected function _validateNextStatus($nextStatus)
    	// make exception for initial adding of open status
    	if ($nextStatus === 'open' && count($this->statusChanges) === 0) {
    		return true;
    	// validate status changes map
    	$allowedChangeMap = array(
    		'open' => array('submitted'),
    		'submitted' => array('approved', 'disapproved'),
    		'approved' => array('final', 'disapproved'),
    		'disapproved' => array('submitted', 'approved'),
    		'final' => array(),
    	$currentStatus = $this->getStatus();
    	if (in_array($nextStatus, $allowedChangeMap[$currentStatus], true)) {
    		return true;
    	return false;
    // ...

A new TimeSheetStatusChange should have a dateApplied that is equal or more recent than the most recent TimeSheetStatusChange

The dateApplied property is settable because it can’t be assumed that the time at which the change is entered into the application reflects the actual time of the change (An example would be paper forms that are collected and entered once a week). Therefore the dateApplied property is validated at the same time the status is validated. Furthermore, once added, the dateApplied property cannot be changed so there is no setter. When loading a TimeSheet, the TimeSheetStatusChanges are fetched in order by specifying the orderBy attribute in the association.


class TimeSheet
    // ...
     * @OneToMany(targetEntity="Domain\Entity\TimeSheetStatusChange", mappedBy="timeSheet", cascade={"persist"}, orphanRemoval=true)
     * @OrderBy({"dateApplied" = "ASC", "id" = "ASC"})
    private $statusChanges;
    // ...
     * Validates if the date of the statusChange given is later than the date
     * of the last statusChange present
    protected function _validateNextStatusChangeDate(TimeSheetStatusChange $statusChange)
    	// if no statusChanges present yet any date is valid
    	if (count($this->statusChanges) === 0) {
    		return true;
    	$currentDate = $this->getLastStatusChange()->getDateApplied();
    	$nextDate = $statusChange->getDateApplied();
    	// enable once tests finish
		return ($nextDate >= $currentDate);
    // ...

TimeSheetStatusChanges must have reference to a TimeSheet but at the same time must be valid.

It can be prevented to store a TimeSheetStatusChange without reference to a TimeSheet by explictly specifying the joinColumn and setting the nullable attribute to false.


class TimeSheetStatusChange
    // ...
     * @ManyToOne(targetEntity="Domain\Entity\TimeSheet", inversedBy="statusChanges")
     * @JoinColumn(name="timesheet_id", referencedColumnName="id", nullable=false)
    private $timeSheet;
    // ...

This still leaves the possibility to set ‘any’ TimeSheet and thereby skipping validation that would normally be done in TimeSheet::addStatusChange(). To prevent this, TimeSheetStatusChange::setTimeSheet() verifies if the TimeSheet’s last status is the current TimeSheetStatusChange instance.


class TimeSheetStatusChange
    // ...
     * Sets the timeSheet.
     * Purpose is to have the reference to the timeSheet set when adding a 
     * new TimeSheetStatusChange to a TimeSheet. Therefore this method validates
     * if the timeSheet has the this instance as the last statusChange.
     * @param TimeSheet $timeSheet
    public function setTimeSheet(TimeSheet $timeSheet)
    	if ($timeSheet->getLastStatusChange() !== $this) {
    		throw new \InvalidArgumentException('Cannot set TimeSheet if not having current instance as lastStatusChange');
    	$this->timeSheet = $timeSheet;
    // ...

The result is that the only way to create a persistable TimeSheetStatusChange is by adding it to the TimeSheet which will:

  1. Validate if the status change is allowed
  2. Adds it to the list of status changes, making it the ‘last’ status change
  3. In turn set the timeSheet reference on the TimeSheetStatusChange

Persisting a TimeSheet is propagated to the status changes by setting the cascade attribute in the association definition


class TimeSheetStatusChange
    // ...
     * @OneToMany(targetEntity="Domain\Entity\TimeSheetStatusChange", mappedBy="timeSheet", cascade={"persist"}, orphanRemoval=true)
     * @OrderBy({"dateApplied" = "ASC", "id" = "ASC"})
    private $statusChanges;
    // ...

Where the domain model goes beyond the database model

One might be tempted to see an ORM as merely a convenient way to access and manipulate data contained in a database. While Doctrine 2 fits such a scenario (Entities can be modeled to reflect an already existing database) the real power shines when entities are modeled with business logic in mind instead of just being a collection of setters and getters matching the database fields.

Features displayed in these examples that can not be expressed in a database model include:

  • Forcing each timesheet to start with a status ‘open’
  • Only allowing specific timesheet status changes:
    • By preventing ‘floating’ timesheetsStatusChanges to be created.
    • By preventing change of dateApplied and status properties, once created
  • Make values, once persisted immutable. (the registrant property of a timesheet, the status and dateApplied properties of a timesheetStatusChange)


As the previous example shows, a Domain model is more than a database model: It should be modeled to comply with business logic required. As a result, for the code in this article I have used Doctrine tools only for creating entity proxies, not the entities themselves

Furthermore, the fact that business logic is only contained in the Domain layer is illustrated by the facts that in this setup:

  • There is no front-end
  • There is no framework (Zend Framework, Symfony, Cake, Yii, Solar, all possibilities are open)
  • There is no specific database. For the tests an in-memory SQLite database is used. In production this will obviously not be the case.

Because of this isolation from other layers, domain logic is easily testable as can be seen from the test cases.

Credits for the test setup used here go to the example found on Giorgio Sironi’s ddd-talk GitHub page.

In future articles I intend to expand on this case, showing how Domain Driven Design can be used in an Agile development process.

  • Twitter
  • LinkedIn
  • Facebook
  • StumbleUpon
  • DZone

9 Responses to “DDD using Doctrine 2: A case study”

  1. Don’t think it’s spam :) Just “by the way” so to speak. For those interested in DDD in PHP using Doctrine 2 I also recommend reading a series of posts on my blog, starting with this one: http://blog.lcf.name/2011/05/application-overview.html

  2. Tomas Dermisek


    this is an excellent article.

    I also checked your github and I am wondering whether or how you’re gonna implement the TimeSheetStatusChangeRepository? Shouldn’t be TimeSheetStatusChange part of Timesheet Agreggate Root and thus shouldn’t require its own Repository class?

    This is gonna be probably in the next part, but I can’t wait :)

    I’ve been also experimenting with the same topic: https://github.com/dermo666/Alfa and I stumbled upon all these tiny details like how to track/add created_by & created_at in an elegant way without too much cluttering the domain layer with infrastructure (see the Invoice::prePersist() or Invoice::preUpdate()). What’s your opinion on that?

    Thanks. Regards, Tomas

  3. Interesting points.

    TimeSheetStatusChange entities are indeed likely to become part of a TimeSheet aggregate root. One of the future requirements I have in mind is tracking status changes that are overdue (‘the lazy manager detector feature’). But, admittedly, even then it might be preferable to implement such a feature on the TimeSheet repository and find overdue TimeSheets instead of TimeSheetStatusChanges.

    As for the lifecycle events in your Invoice entity, I’ve been experimenting with those as well and I’m looking for ways to avoid having to copy the same ‘timestampable’ behavior across entities.

    I’ll definitely take above points into account when expanding on this case (In true Agile fashion, the direction of the next iteration is yet to be determined ;))

  4. Nino Martincevic


    Sadly I see many issues and problems with your example:

    A fundamental pattern of DDD is completely missing here: Aggregate.
    An Aggregate maintains its own consistency and of all related “parts”.

    A TimeSheetStatusChange (TSSC) as an Entity makes no sense: it has no meaning without a TimeSheet,
    has no lifecycle (it gets replaced/followed by another) and is almost surely just a Value Object.
    I see a Timesheet as the Aggregate (Root) and TSSC as a related Value Object building perhaps
    an Entity TimeSheetVersion (a simple collection of state changes).

    User is also a part of it, but just a plain reference to its ID.
    It has no further meaning in the “TimeSheeting” context.

    The way you did implement it, you have created a bidirectional relation
    from TimeSheet to TimeSheetStatusChange (with addStatusChange) and vice versa (setTimeSheet).
    If TimeSheet is the AR there is absolutely no need for it and such associations should
    be avoided like the plague, btw.
    Doing it that way it’s just a relational thinking packed into DDD-style.

    The “art” (and hard work) of DDD is to find Boundaries (and Bounded Contexts), have one Aggregate
    in charge of fulfilling the work and to ensure consistency inside of it.
    Just by making every object an Entity you do not gain any advantages you’d otherwise have
    compared to a “traditional” ORM-style model.

    “TimeSheetStatusChanges must have reference to a TimeSheet but at the same time must be valid.”
    You did solve that with help of Doctrine.
    Is that a true Domain Model?
    Try it: as long as you can remove all Doctrine annotations off the classes and your tests will still work,
    it is. Otherwise it’s not, it depends on persistance strategies.

    Status changes as meaningful Value Objects & _validateNextStatus:
    A very important thing in DDD is to make implicit concepts explicit:
    Instead of building string and array comparisons of a status why don’t you encapsulate the different
    state changes into meaningful Value Objects?
    Like TimeSheetSubmitted, TimeSheetApproved, TimesheetDisapproved, …

    To check the allowed transitions you can use Specifications then, instead of hiding the concept
    in a protected method and an array that has to be translated to be understood.
    Example (pseudo-code):

    Just because Doctrine propagates DDD it does not mean it is simple to do it with it.
    In fact DDD is completely agnostic of any technology. And one of the biggest issues with
    Doctrine is that it does not support one of the most important pattern of DDD, the Value Object.

    Doctrine 2 does not exist to make DDD possible. It helps in persisting Domain Objects you would otherwise
    have to map yourself to database structures. But that was even possible with Doctrine 1.x too…

  5. Thanks for the elaborate reply.

    As acknowledged in my pervious reply it is probably best to change the TimeSheet into an aggregate root.

    As for whether or not the TimeSheetStatusChanges should be considered value objects or entities. I consider the status change to be more than simply an attribute of the TimeSheet: It’s the representation of an action performed by a specific user on a specific timesheet at a specific time. An addition to come will be the possibility to add a comment to the status change, e.g. “Registered this week’s meetings to project X”.

    It’s true though that TimeSheetStatusChange has no meaning without a TimeSheet. For other readers: Feature proposal of Value Object support for Doctrine 2.

    Moving the concept of validating a status change to a separate policy will certainly take place as in future there will also be validation of whether or not the user adding the status change is actually allowed to do that.

    What would your thoughts be on: Having an ordered list of value objects (TimeSheetStatusChange) in the TimeSheet and applying aforementioned policy when adding a new one?

  6. Nino Martincevic

    Hi Tibo,

    sorry, did overread the part with the aggregates.

    I understand your motivation of making TimeSheetStatusChange a separate concept but your explanation for this can be used for every kind of status change.
    Actually it’s common to model the “act of doing something” as an Entity, or to model a Moment-Interval (or Transaction) itself as e.g. MoneyTransfer, Assignment or Transition.

    But in this example I don’t recognize a need for something like this.
    Additionally you could also enrich you VO with an comment.
    That’s not the point, managing identity and lifecycle decides if it is an Entity, not additional attributes.
    And because VOs can even hold Entities as attributes there is no problem with using it in this example.

    Answering your question:
    yes – or having that list as a separate object, like TimeSheetCourse or that like.
    (this is quite analogous to Erics Route/Leg of the Cargo example domain)


  7. Greate study case. One question (you made me courious). What software did you use to paint http://www.tibobeijen.nl/blog/wp-content/uploads/2011/06/yuml-ddd-hrm-001.21-500×46.png ?

  8. The diagram is made using yuml.me. See docs/yuml.001.

  9. Tomas Dermisek

    just FYI: after a long break I started looking into my ‘research project’ and found nice solution for the created by, modified on: http://symfony.com/doc/current/cookbook/doctrine/common_extensions.html

    I did not have time to read the other comments here yet, but it seem to be a nice discussion …

Leave a Reply

(will not be published, required)