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.

🤔 The Problem

I needed to calculate the availability of one or more users between a start and end date. Returning a collection of “slots” where one or more user is available so that a booking can be made for that slot. We needed take into account a range of data, including external:

  • User’s working hours
  • If the date is in the past
  • Any absences the user has defined
  • Any other bookings the user has
  • External calendar entries

If any one of these checks fail then the slot is not available for the user. The other consideration is performance. Ideally we don’t want to loop over all the slots between start/end doing all of the checks for each, if one fails then the slot is unavailable so we want to exit early. So, we have three main considerations here:

  1. Need to loop over a known time period, finding “slots” of known length where a user is available
  2. Take into account a range of other data including externally sourced
  3. Do this check as fast as possible with the least amount of wasted computation

💡 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.
}

📝 Conclusion

In conclusion, by combining the flexibility of PHP’s invokable classes and the power that Carbon Period offers with it’s filter stack we can create an efficient way to take a lot of dates in a range and narrow it down to a select few.

This structure is what I used to build a slot based booking system. Going from ~400 slots down to none, if needed, with very little work and a lot of easy to read and well structured classes.

You can find out more about Carbon and the Carbon Period class on Carbon’s documentation: https://carbon.nesbot.com/docs/

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