PHP
Filtering Carbon Period For Flexibility And Performance
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:
1 $startDate = Carbon::parse('2021-01-01 00:00:00');
2 $period = $startDate->toPeriod('2021-01-07 00:00:00', 30, 'minutes');
3 foreach($period as $date) {
4 echo $slot->toDateTimeString(); // 2021-01-01 00:00:00, 2021-01-01 00:30:00 ...
5 }
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 to 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:
Need to loop over a known time period, finding "slots" of known length where a user is available
Take into account a range of other data, including externally sourced
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:
1 $workingHours = [
2 1 => [
3 'day' => 1, // Monday
4 'start_time' => '09:00',
5 'end_time' => '17:00',
6 ],
7 3 => [
8 'day' => 3, // Wednesday
9 'start_time' => '12:00',
10 'end_time' => '17:00',
11 ],
12 // ...
13 ];
We could in a naive way write this as follows, without filters:
1 $startDate = '2021-11-27 00:00:00'; // Friday
2 $dates = $startDate->toPeriod('2021-11-29 00:00:00', 30, 'minutes')->toArray();
3 $availableDates = [];
4
5 foreach($dates as $date) {
6 // Check working hours
7 $startTime = $date->copy()->setTimeFromTimeString($workingHours[$date->dayOfWeek]['start_time']);
8 $endTime = $date->copy()->setTimeFromTimeString($workingHours[$date->dayOfWeek]['end_time']);
9 if(
10 $date->greaterThanOrEqualTo($startTime)
11 && $date->lessThanOrEqualTo($endTime)
12 ) {
13 $availableDates[] = $date;
14 }
15 }
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, let’s see how filters
can help with this.
Carbon Period Filters
Our example above would be rewritten as follows:
1 $startDate = '2021-11-27 00:00:00'; // Friday
2 $dates = $startDate->toPeriod('2021-11-29 00:00:00', 30, 'minutes');
3
4 $dates->addFilter(function($date) {
5 $startTime = $date->copy()->setTimeFromTimeString($workingHours[$date->dayOfWeek]['start_time']);
6 $endTime = $date->copy()->setTimeFromTimeString($workingHours[$date->dayOfWeek]['end_time']);
7
8 return
9 $date->greaterThanOrEqualTo($startTime)
10 && $date->lessThanOrEqualTo($endTime)
11 });
12
13 $availableDates = $dates->toArray();
14
15 // or
16
17 foreach($dates as $date) { ... }
While the amount of code is very similar, using filters like this offers two key benefits:
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.Each filter is isolated in its 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 that returns false will "remove" that date from the range, and no other filters will be run. Meaning the deeper your filters get the technically, fewer 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:
1 $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 filter’s 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 its 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.
1 class WorkingHoursFilter {
2 public function __invoke($date) {
3 $startTime = $date->copy()->setTimeFromTimeString($workingHours[$date->dayOfWeek]['start_time']);
4 $endTime = $date->copy()->setTimeFromTimeString($workingHours[$date->dayOfWeek]['end_time']);
5
6 return
7 $date->greaterThanOrEqualTo($startTime)
8 && $date->lessThanOrEqualTo($endTime)
9 }
10 }
11
12 $startDate = '2021-11-27 00:00:00'; // Friday
13 $dates = $startDate->toPeriod('2021-11-29 00:00:00', 30, 'minutes');
14 $dates->addFilter(new WorkingHoursFilter);
15
16 $availableDates = $dates->toArray();
17
18 // or
19
20 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:
1 class WorkingHoursFilter {
2 protected $workingHours;
3
4 public function __construct($workingHours) {
5 $this->workingHours = $workingHours;
6 }
7
8 public function __invoke($date) {
9 $startTime = $date->copy()->setTimeFromTimeString($this->workingHours[$date->dayOfWeek]['start_time']);
10 $endTime = $date->copy()->setTimeFromTimeString($this->workingHours[$date->dayOfWeek]['end_time']);
11
12 return
13 $date->greaterThanOrEqualTo($startTime)
14 && $date->lessThanOrEqualTo($endTime)
15 }
16 }
17
18 $startDate = '2021-11-27 00:00:00'; // Friday
19 $dates = $startDate->toPeriod('2021-11-29 00:00:00', 30, 'minutes');
20 $dates->addFilter(new WorkingHoursFilter($workingHours));
21
22 $availableDates = $dates->toArray();
23
24 // or
25
26 foreach($dates as $date) { ... }
PHP will keep the past 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.
1 $startDate = '2021-11-27 00:00:00'; // Friday
2 $dates = $startDate->toPeriod('2021-11-29 00:00:00', 30, 'minutes');
3
4 $dates
5 ->addFilter(new WorkingHoursFilter($workingHours))
6 ->prependFilter(new IsPastFilter())
7 ->addFilter(new AbsenceFilter($absences))
8 ->addFilter(new OtherBookingsFilter($absences))
9 ->addFilter(new ExternalCalendarFilter($events))
10 ;
11
12 $availableDates = $dates->toArray(); // This is our filterd range of dates
13
14 // or
15
16 foreach($dates as $date) {
17 // $date will always be a date that has passed all of our filters.
18 }