PHP

šŸ• Filtering Carbon Period For Flexibility And Performance

Carbon's Carbon Period class allows for easy iteration over a range of dates.

I recently needed to work on a slot based booking system at work. This system needed to accept a few parameters and return "slots" of time where that user is available. I managed to do this in a flexible way by making use of Carbon's CarbonPeriod class and filters. Lets take a look!

šŸ˜“

TL;DR

Carbonā€™s CarbonPeriod class allows you construct an iterable range of dates. Each period can then have multiple filters added that reduces the ā€œcorrectā€ dates down in the range. You can make use of PHPā€™s invokable classes to create small, isolated filters to that in the end returns you an array of dates that passed all of the filter checks. All of this in a memory efficient way as CarbonPeriod doesnā€™t create an array of dates in memory, rather acting as an iterable itself applying the filters on demand.

ā“ What is Carbon Period?

Letā€™s start with the basics. Carbon Period is a class of the Carbon PHP library providing a way to create an iterable range. You specify a start date, end date and then an interval. Each iteration of the loop will increment the current date by the interval until you reach the end date. For example, the example snippet below will create a new Carbon Period from the start date echoing every 30 minute interval until end date:

$startDate = Carbon::parse('2021-01-01 00:00:00');
$period = $startDate->toPeriod('2021-01-07 00:00:00', 30, 'minutes');

foreach($period as $date) {
	echo $slot->toDateTimeString(); // 2021-01-01 00:00:00, 2021-01-01 00:30:00 ...
}

This makes it extremely useful to loop over a range. However, the power of this class really shows when you make use of filters.

{{ partial:components/section background="bg-gray-100" seperator_background="after:bg-gray-100" }}

šŸ’” The Solution

I used a couple different concepts within PHP to create, what I feel, is a well structured, fast (can probably be faster) and easy to extend/maintain system. To start, letā€™s look at a basic example. Assuming each of our users have their working hours defined in the following format:

$workingHours = [
	1 => [
		'day' => 1, // Monday
		'start_time' => '09:00',
		'end_time' => '17:00',
	],
	3 => [
		'day' => 3, // Wednesday
		'start_time' => '12:00',
		'end_time' => '17:00',
	],
	// ...
];

We could in a naive way write this as follows, without filters:

$startDate = '2021-11-27 00:00:00'; // Friday
$dates = $startDate->toPeriod('2021-11-29 00:00:00', 30, 'minutes')->toArray();

$availableDates = [];

foreach($dates as $date) {
	// Check working hours
	$startTime = $date->copy()->setTimeFromTimeString($workingHours[$date->dayOfWeek]['start_time']);
	$endTime = $date->copy()->setTimeFromTimeString($workingHours[$date->dayOfWeek]['end_time']);

	if(
		$date->greaterThanOrEqualTo($startTime)
		&& $date->lessThanOrEqualTo($endTime)
	) {
		$availableDates[] = $date;
	}	
}

The example above may not be perfect. But it would get the job done. We construct two Carbon instances to represent the time we start work on the same day, and the time we finish work. We then check to see if weā€™re between our working hours. If we are, great the date is available.

This would work, our first consideration is handled. Weā€™re looking over known slots. Our second consideration could also be met, there are a couple of ways we could tackle adding more conditions; we could chain more optional checks onto the if statement, preventing earlier checks from being executed. Or we could add additional if conditions to handle those. Both of these options didnā€™t feel right though.

So, lets see how filters can help with this.

Carbon Period Filters

Our example above would be rewritten as follows:

$startDate = '2021-11-27 00:00:00'; // Friday
$dates = $startDate->toPeriod('2021-11-29 00:00:00', 30, 'minutes');

$dates->addFilter(function($date) {
	$startTime = $date->copy()->setTimeFromTimeString($workingHours[$date->dayOfWeek]['start_time']);
	$endTime = $date->copy()->setTimeFromTimeString($workingHours[$date->dayOfWeek]['end_time']);

	return 
		$date->greaterThanOrEqualTo($startTime)
		&& $date->lessThanOrEqualTo($endTime)
});

$availableDates = $dates->toArray();

// or

foreach($dates as $date) { ... }

While the amount of code is very similar, using filters like this offers two key benefits:

  1. Youā€™re not using memory up front as you donā€™t start constructing your available dates until you call toArray or start looping over the results.
  2. Each filter is isolated in itā€™s own call, keeping it easier to maintain.

Filters in the Carbon Period are also stored in a stack and are run after each other. You can add as many filters as you like. The first the returns false will ā€œremoveā€ that date from the range and no other filters will be run. Meaning the deeper your filters get the, technically, less dates it has to process. This means your more processing heavy filters should be last. In the example I gave at the beginning of this article, this means data where we need to call as external service should be last, as we may never get that far so save the need to process it.

Itā€™s worth noting too that passing a string to addFilter will call the function on the Carbon instance. For example:

$period->addFilter('isWeekday');

This will only include dates which are on a weekday.

Carbon Period offers some additional helpers to work with the filter stack, these are:

  • prependFilter which will add your filter to the start of the filters stack, making it get called first.
  • hasFilter returns true or false on whether the given filter exists on the Carbon Period.
  • setFilters replaces the filter stack with what you provide.
  • resetFilters clears all filters defined on the period.

Letā€™s tacking making this a bit more organised and easier to expand on and maintain moving forward.

Invokable Classes

The addFilter method requires a callable to be passed. This means you cannot simply pass a class instance. However, if you make the class invokable it will be treated as a callable. Allowing you to create small classes to handle the filtering for you, if needed you can also pass through state based data in the constructor. This has multiple benefits including:

  1. Your filtering logic is kept in itā€™s own class file
  2. Your filters become reusable on multiple carbon period instances
  3. Your filtering logic is kept isolated from the rest of your application, or other filters

Letā€™s take our example above and move it into an invokable class, for the purposes of this post itā€™ll all be in a single ā€œfileā€ but in your application these would be different files.

class WorkingHoursFilter {
	public function __invoke($date) {
		$startTime = $date->copy()->setTimeFromTimeString($workingHours[$date->dayOfWeek]['start_time']);
		$endTime = $date->copy()->setTimeFromTimeString($workingHours[$date->dayOfWeek]['end_time']);

		return 
			$date->greaterThanOrEqualTo($startTime)
			&& $date->lessThanOrEqualTo($endTime)
	}
}

$startDate = '2021-11-27 00:00:00'; // Friday
$dates = $startDate->toPeriod('2021-11-29 00:00:00', 30, 'minutes');

$dates->addFilter(new WorkingHoursFilter);

$availableDates = $dates->toArray();

// or

foreach($dates as $date) { ... }

As you can see above, our code is the same. However, weā€™re organising our filters in a much better way, by keeping them separate from the rest of the code and reusable across our application.

We can take this a step further and allow our filter to accept state data that is valid for the entire period. For example, what if weā€™re checking this for multiple users, we would want to pass the working hours into our filter:

class WorkingHoursFilter {
	protected $workingHours;

	public function __construct($workingHours) {
		$this->workingHours = $workingHours;
	}
	
	public function __invoke($date) {
		$startTime = $date->copy()->setTimeFromTimeString($this->workingHours[$date->dayOfWeek]['start_time']);
		$endTime = $date->copy()->setTimeFromTimeString($this->workingHours[$date->dayOfWeek]['end_time']);

		return 
			$date->greaterThanOrEqualTo($startTime)
			&& $date->lessThanOrEqualTo($endTime)
	}
}

$startDate = '2021-11-27 00:00:00'; // Friday
$dates = $startDate->toPeriod('2021-11-29 00:00:00', 30, 'minutes');

$dates->addFilter(new WorkingHoursFilter($workingHours));

$availableDates = $dates->toArray();

// or

foreach($dates as $date) { ... }

PHP will keep the passed in state in the constructor for each invocation of the class.

Now for a slightly more complete example, none of the filter classes are included here but it should give the idea of how powerful this feature can be.

$startDate = '2021-11-27 00:00:00'; // Friday
$dates = $startDate->toPeriod('2021-11-29 00:00:00', 30, 'minutes');

$dates
	->addFilter(new WorkingHoursFilter($workingHours))
	->prependFilter(new IsPastFilter())
	->addFilter(new AbsenceFilter($absences))
	->addFilter(new OtherBookingsFilter($absences))
	->addFilter(new ExternalCalendarFilter($events))
;

$availableDates = $dates->toArray(); // This is our filterd range of dates

// or

foreach($dates as $date) {
	// $date will always be a date that has passed all of our filters.
}
{{ partial:components/section background="bg-gray-100" seperator_background="after:bg-gray-100" }}

Tagged Under: Dates, Carbon, Range, Period, Performance, Filtering, Reusability, Code Structure, Booking,