Validating Dates With Symfony

Victor Todoran
4 min readFeb 19, 2023

--

Photo by Nick Hillier on Unsplash

Symfony has a quite powerful Validator Component which comes in handy in a myriad of situations. Lately I’ve been working a lot with Dates and Periods which need to be validated.

Symfony has some Date Constraints (things it can validate out of the box) but most of them are useful to validate if something is a valid date/date format or not.

I’ve noticed that people, which have not worked with Symfony in the past, quickly peruse the Date Constraints section of the documentation and then they start implementing their own Validators.

That is because they don’t need to check whether something is a valid Date/Date format or not, they need to check if an endDate is greater than a startDate, if a Date is in the future or in the past and so on and so forth.

What people seem to overlook is that some of the Comparison Constraints also work with Dates.

Using these Comparison Constraints with Dates is something that we are going to explore today.

Let us imagine we have a very simple InvoicePeriod class

class InvoicePeriod
{
public readonly DateTimeImmutable $startDate;
public readonly DateTimeImmutable $endDate;

public function __construct(
DateTimeImmutable $startDate,
DateTimeImmutable $endDate
) {
$this->startDate = $startDate;
$this->endDate = $endDate;
}
}

One of the things we might want to validate is that both startDate and endDate are not future Dates. That is because in our fictional application you can not invoice a future period.

Let’s quickly run a failing test before we configure any Validation.

namespace App\Tests\Integration\DTO;

use App\DTO\InvoicePeriod;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Validator\Validator\ValidatorInterface;

class InvoicePeriodTest extends KernelTestCase
{
public function testFutureInvoicePeriodIsInvalid(): void
{
$validator = self::getContainer()->get(ValidatorInterface::class);
$futureInvoicePeriod = new InvoicePeriod(
new \DateTimeImmutable('2030-01-01'),
new \DateTimeImmutable('2030-01-01')
);

$constraintViolationList = $validator->validate($futureInvoicePeriod);

$this->assertGreaterThan(
0,
$constraintViolationList->count()
);
}
}

Now we will configure a LessThanOrEqual Constraint to make that test pass. Supposing we use PHP attributes to configure this Constraint, our InvoicePeriod will look something like this:

use Symfony\Component\Validator\Constraints as Assert;

class InvoicePeriod
{
#[Assert\LessThanOrEqual('today')]
public readonly DateTimeImmutable $startDate;
#[Assert\LessThanOrEqual('today')]
public readonly DateTimeImmutable $endDate;

... unchanged ...
}

If you run the test again, it should pass. If it does not, try clearing the cache, validation configurations are cached.

The business logic of our fictional application also requires that the Invoice.startDate can not be greater than the Invoice.endDate.

For this we will use the GreaterThanOrEqual Constraint. Because we want to validate a property in relation to another we will also leverage the configuration called property path.

But first let’s write a failing test:

class InvoicePeriodTest extends KernelTestCase
{
public function testInvoiceWithStartDateGreaterThanEndDateIsInvalid(): void
{
$validator = self::getContainer()->get(ValidatorInterface::class);
$invoicePeriod = new InvoicePeriod(
new \DateTimeImmutable('2023-01-31'),
new \DateTimeImmutable('2023-01-01')
);

$constraintViolationList = $validator->validate($invoicePeriod);

$this->assertGreaterThan(
0,
$constraintViolationList->count()
);
}
}

Now let us configure the new Constraint on the InvoicePeriod:

class InvoicePeriod
{
... unchanged ...

#[Assert\GreaterThanOrEqual(propertyPath: 'startDate')]
public readonly DateTimeImmutable $endDate;

... unchanged ...
}

If you run the test again, it should pass.

Before we wrap this up let’s consider another case.
At the moment I’m writing this it is 2023–02–19 14:45.
Consider the following Invoice Period and our configured constraints, should it fail or not?

$invoicePeriod = new InvoicePeriod(
new \DateTimeImmutable('2023-01-01 00:00:00'),
new \DateTimeImmutable('2023-02-19 20:45:00')
);

The endDate is the same as today, but the hour is six hours into the future. So, endDate is into the future, and the validation should and will fail.
Try plugging this data into InvoicePeriodTest::testFutureInvoicePeriodIsInvalid and the test will pass.

But business logic is messy. The dates might be coming from outside the app and we are not allowed to change them.
So Product comes and tells you that as long as the endDate is not into the future, the time is irrelevant, the InvoicePeriod must be considered valid.

First let us write a failing test for that.

class InvoicePeriodTest extends KernelTestCase
{
public function testTimeIsIgnored(): void
{
$validator = self::getContainer()->get(ValidatorInterface::class);

$startDate = (new \DateTimeImmutable())->setTime(0, 0);
$endDate = (new \DateTimeImmutable())->setTime(23, 59, 59);
$invoicePeriod = new InvoicePeriod($startDate, $endDate);

$constraintViolationList = $validator->validate($invoicePeriod);

$this->assertCount(0, $constraintViolationList);
}
}

Let us revisit the InvoicePeriod class

class InvoicePeriod
{
#[Assert\LessThanOrEqual('today')]
public readonly DateTimeImmutable $startDate;
#[Assert\LessThanOrEqual('today')]
#[Assert\GreaterThanOrEqual(propertyPath: 'startDate')]
public readonly DateTimeImmutable $endDate;

... constructor ...
}

When configuring the Constraint, we can pass any date string accepted by the DateTime constructor (e.g. today). Which means we can also use relative or time formats.

Let’s configure the LessThanOrEqual Constraint of the endDate as follows:

... unchanged ...

#[Assert\LessThanOrEqual('today 23:59:59')]
#[Assert\GreaterThanOrEqual(propertyPath: 'startDate')]
public readonly DateTimeImmutable $endDate;

... unchanged ...

If you run InvoicePeriodTest::testTimeIsIgnored it should now pass.

This only scratches the surface of what is possible with Dates and Comparison Constraints.

For example you could validate that a Date is in a given month by leveraging the Range Constraint and relative formats accepted by the DateTime constructor.

class PropertyTaxPayment
{
#[Assert\Range(
min: 'first day of January',
max: 'last day of January',
)]
private $paidAt;
}

Hopefully, if you are using Symfony, I’ve convinced you to take a hard look at the Validator Component before implementing some DateTime validation logic inside your App.

That is it! Thanks for reading!

Disclaimer: Consider this to be a living document, which means it’s subject to undocumented changes and it might even die in the future.

--

--

Victor Todoran

I write, read and think about software and its users for a living