Building a Calendar with Carbon

Feature image: Building a Calendar with Carbon

Have you ever wanted to create a custom calendar in your PHP code but weren’t sure how to approach it? How do you structure it? How do you consider the differences in the length of each month? How do you render a month with not just the current month’s numbers but also the leading and trailing days from the previous and next months? Thankfully, Carbon has a few often-unused tools we can leverage to make this a smoother process.

What We’ll Be Building

Today, we’ll be building a Calendar PHP class and a Month Blade component using Carbon, its CarbonPeriod feature, and Laravel’s Collections. We’ll place the Blade component in a few different contexts where it might be valuable to be able to navigate to all of the days of a current month or multiple months. This will be a good starting point for creating a custom day planner.

Carbon and CarbonPeriod

You’re likely familiar with Carbon, PHP’s most popular date library. If you use Laravel, you might even be using Carbon without realizing it since Laravel models return Carbon instances by default for the created_at and updated_at timestamps.

However familiar you are with Carbon, you may not know that Carbon also has a CarbonPeriod class that represents (and lets you work with) a range of dates. CarbonPeriod has a lot of options that are worth looking into, but for our needs, we’ll only be using Carbon’s toPeriod static method, which will give us a CarbonPeriod instance with our range of dates. We’ll then immediately convert that instance into an array that we’ll use to build our calendar.

Getting Started

Let’s start by creating a Calendar support class with a buildMonth method that expects integers to represent a year and month. Our method will create a new CarbonImmutable instance from those values. We’ll go ahead and return an array with year and month values which can be used to label our rendered month.

<?php
 
namespace App\Support;
 
use Carbon\CarbonImmutable;
 
class Calendar
{
public static function buildMonth($year, $month)
{
$startOfMonth = CarbonImmutable::create($year, $month, 1);
 
return [
'year' => $startOfMonth->year,
'month' => $startOfMonth->format('F'),
];
}
}

Using our Calendar class will look like this:

Calendar::buildMonth(year: 2022, month: 2);

A Range of Dates

Now that we have our buildMonth method with a CarbonImmutable date, let’s update it to get the ending boundary for the month. We can use Carbon’s endOfMonth method for this.

public static function buildMonth($year, $month)
{
$startOfMonth = CarbonImmutable::create($year, $month, 1);
+ $endOfMonth = $startOfMonth->endOfMonth();
 
return [
'year' => $startOfMonth->year,
'month' => $startOfMonth->format('F'),
];
}

Note: we’re using CarbonImmutable instead of Carbon so that every time we call a method like endOfMonth(), we get a new instance instead of accidentally modifying the original instance. If we wanted to use Carbon instead, we would need to call $startOfMonth->copy() before the modifier to ensure we have a new Carbon instance to work with instead of modifying the original instance. See the Carbon docs for more information.

Next, we can use Carbon’s toPeriod method to get a CarbonPeriod instance representing the period between the two dates.

public static function buildMonth($year, $month)
{
$startOfMonth = CarbonImmutable::create($year, $month, 1);
$endOfMonth = $startOfMonth->endOfMonth();
 
return [
'year' => $startOfMonth->year,
'month' => $startOfMonth->format('F'),
+ 'dates' => $startOfMonth->toPeriod($endOfMonth),
];
}

Now, we want to convert our CarbonPeriod instance into an array and map over it. For now, we want each date to return a URL path and the day number for rendering our month.

public static function buildMonth($year, $month)
{
$startOfMonth = CarbonImmutable::create($year, $month, 1);
$endOfMonth = $startOfMonth->endOfMonth();
 
return [
'year' => $startOfMonth->year,
'month' => $startOfMonth->format('F'),
- 'dates' => $startOfMonth->toPeriod($endOfMonth),
+ 'dates' => return collect($startOfMonth->toPeriod($endOfMonth)->toArray())
+ ->map(fn ($date) => [
+ 'path' => $date->format('/Y/m/d'),
+ 'day' => $date->day,
+ ]),
];
}

That’s a good start, but since we want to render these day numbers in a month view, we need to split the dates so there are chunks of exactly seven days for each week.

We can use the chunk method on our Collection to split our array into chunks of seven dates. However, our total number of days is not guaranteed to be a multiple of seven, and the first day of the month is not guaranteed to be on Sunday.

To solve this, we need to include any leading and trailing dates from the boundary months in our CarbonPeriod instance, whether we render those dates or not. We can use Carbon’s startOfWeek and endOfWeek methods for this, but we’ll keep our $startOfMonth and $endOfMonth variables since we’ll later want to determine if each day is within the month we’re rendering.

public static function buildMonth($year, $month)
{
$startOfMonth = CarbonImmutable::create($year, $month, 1);
$endOfMonth = $startOfMonth->endOfMonth();
+ $startOfWeek = $startOfMonth->startOfWeek();
+ $endOfWeek = $endOfMonth->endOfWeek();
 
return [
'year' => $startOfMonth->year,
'month' => $startOfMonth->format('F'),
- 'dates' => return collect($startOfMonth->toPeriod($endOfMonth)->toArray())
+ 'dates' => return collect($startOfWeek->toPeriod($endOfWeek)->toArray())
->map(fn ($date) => [
'path' => $date->format('/Y/m/d'),
'day' => $date->day,
]),
];
}

Depending on your preference, you may notice this gives you an unexpected result. Carbon’s default start of the week is Monday. So, if you’re expecting Sunday, you’ll need to pass in the desired constants from Carbon.

public static function buildMonth($year, $month)
{
$startOfMonth = CarbonImmutable::create($year, $month, 1);
$endOfMonth = $startOfMonth->endOfMonth();
- $startOfWeek = $startOfMonth->startOfWeek();
- $endOfWeek = $endOfMonth->endOfWeek();
+ $startOfWeek = $startOfMonth->startOfWeek(Carbon::SUNDAY);
+ $endOfWeek = $endOfMonth->endOfWeek(Carbon::SATURDAY);
 
return [
'year' => $startOfMonth->year,
'month' => $startOfMonth->format('F'),
'dates' => return collect($startOfWeek->toPeriod($endOfWeek)->toArray())
->map(fn ($date) => [
'path' => $date->format('/Y/m/d'),
'day' => $date->day,
]),
];
}

Now that we know our CarbonPeriod instance and the resulting collection contain a number of items that’s a multiple of seven, we can use the Collection chunk method to split the array into nested arrays with seven items each.

public static function buildMonth($year, $month)
{
$startOfMonth = CarbonImmutable::create($year, $month, 1);
$startOfMonth = $startOfMonth->startOfMonth();
$endOfMonth = $startOfMonth->endOfMonth();
$startOfWeek = $startOfMonth->startOfWeek(Carbon::SUNDAY);
$endOfWeek = $endOfMonth->endOfWeek(Carbon::SATURDAY);
 
return [
'year' => $startOfMonth->year,
'month' => $startOfMonth->format('F'),
- 'dates' => return collect($startOfWeek->toPeriod($endOfWeek)->toArray())
+ 'weeks' => return collect($startOfWeek->toPeriod($endOfWeek)->toArray())
->map(fn ($date) => [
'path' => $date->format('/Y/m/d'),
'day' => $date->day,
- ]),
+ ])
+ ->chunk(7),
];
}

Now, we’re ready to create a reusable Blade component to render our month.

@props(['weeks'])
 
<table class="m-auto text-center month">
<thead>
<tr>
<th>Su</th>
<th>Mo</th>
<th>Tu</th>
<th>We</th>
<th>Th</th>
<th>Fr</th>
<th>Sa</th>
</tr>
</thead>
<tbody>
@foreach ($weeks as $days)
<tr>
@foreach ($days as $day)
<td>
<a
href="{{ $day['path'] }}"
class="block py-2 hover:bg-gray-300"
>
{{ $day['day'] }}
</a>
</td>
@endforeach
</tr>
@endforeach
</tbody>
</table>
$calendar = Calendar::buildMonth(year: 2022, month: 2);
<div class="p-4 text-4xl text-center text-white bg-gray-900">
<a href="/" class="block">
{{ $calendar['month'] }} {{ $calendar['year'] }}
</a>
</div>
 
<div class="mt-8">
<x-month :weeks="$calendar['weeks']" />
</div>

Month view

Tidying up

This component works fine at this point, but let’s tidy up our code a bit.

First, let’s style the boundary days so they aren’t as visually prominent as the days within the current month.

public static function buildMonth($year, $month)
{
$startOfMonth = CarbonImmutable::create($year, $month);
$endOfMonth = $startOfMonth->endOfMonth();
$startOfWeek = $startOfMonth->startOfWeek(Carbon::SUNDAY);
$endOfWeek = $endOfMonth->endOfWeek(Carbon::SATURDAY);
 
return [
'year' => $startOfMonth->year,
'month' => $startOfMonth->format('F'),
'weeks' => return collect($startOfWeek->toPeriod($endOfWeek)->toArray())
->map(fn ($date) => [
'path' => $date->format('/Y/m/d'),
'day' => $date->day,
+ 'withinMonth' => $date->between($startOfMonth, $endOfMonth),
])
->chunk(7),
];
}
<a
href="{{ $date['path'] }}"
class="
block
py-2
hover:bg-gray-300
+ {{ ! $date['withinMonth'] ? 'text-gray-300' : '' }}
"
>
{{ $date['day'] }}
</a>

Month view

Next, it’d be nice to show which date is currently selected. Let’s allow passing in an optional $day parameter, and create an additional CarbonImmutable instance $selectedDate to represent the currently selected date. We’ll also create a selected property on each date in our response array and set that property to true for the item matching the specified date (if specified).

-public static function buildMonth($year, $month)
-{
- $startofMonth = CarbonImmutable::create($year, $month, 1);
+public static function buildMonth($year, $month, $day = null)
+{
+ $selectedDate = CarbonImmutable::create($year, $month, $day ?? 1);
+ $startOfMonth = $selectedDate->startOfMonth();
$endOfMonth = $selectedDate->endOfMonth();
$startOfWeek = $startOfMonth->startOfWeek(Carbon::SUNDAY);
$endOfWeek = $endOfMonth->endOfWeek(Carbon::SATURDAY);
 
return [
'year' => $selectedDate->year,
'month' => $selectedDate->format('F'),
'weeks' => return collect($startOfWeek->toPeriod($endOfWeek)->toArray())
->map(fn ($date) => [
'path' => $date->format('/Y/m/d'),
'day' => $date->day,
'withinMonth' => $date->between($startOfMonth, $endOfMonth),
+ 'selected' => $day && $date->is($selectedDate),
])
->chunk(7),
];
}
<a
href="{{ $date['path'] }}"
class="
block
py-2
hover:bg-gray-300
{{ ! $date['withinMonth'] ? 'text-gray-300' : '' }}
+ {{ $date['selected'] ? 'font-bold bg-gray-200' : '' }}
"
>
{{ $date['day'] }}
</a>

Month view

Another example: building a year

Let’s look at another final example of how to use our month component; we’ll build a year view and a buildYear method on the Calendar class to support generating a full year of months.

<?php
 
namespace App\Support;
 
use Carbon\Carbon;
 
class Calendar
{
+ public static function buildYear($year)
+ {
+ return [
+ 'year' => $year,
+ 'months' => collect(range(1, 12))
+ ->map(fn ($month) => static::buildMonth($year, $month)),
+ ];
+ }
 
public static function buildMonth($year, $month, $day = null)
{
$selectedDate = CarbonImmutable::create($year, $month, $day ?? 1);
$startOfMonth = $selectedDate->startOfMonth();
$endOfMonth = $selectedDate->endOfMonth();
$startOfWeek = $startOfMonth->startOfWeek(Carbon::SUNDAY);
$endOfWeek = $endOfMonth->endOfWeek(Carbon::SATURDAY);
 
return [
'year' => $selectedDate->year,
'month' => $selectedDate->format('F'),
'weeks' => return collect($startOfWeek->toPeriod($endOfWeek)->toArray())
->map(fn ($date) => [
'path' => $date->format('/Y/m/d'),
'day' => $date->day,
'withinMonth' => $date->between($startOfMonth, $endOfMonth),
'selected' => $day && $date->is($selectedDate),
])
->chunk(7),
];
}
}
$calendar = Calendar::buildYear(2022);
<div class="p-4 mb-8 text-4xl text-center text-white bg-gray-900">
{{ $calendar['year'] }}
</div>
 
<div class="grid grid-cols-3 gap-8 px-8 pb-8">
@foreach ($calendar['months'] as $month)
<div>
<div class="p-4 mb-4 text-2xl text-center text-white bg-gray-100 dark:bg-gray-800">
{{ $month['month'] }}
</div>
 
<x-month :weeks="$month['weeks']" />
</div>
@endforeach
</div>

Year view

What’s Next?

This is a good starting point. At this point, you can generate an array of dates for a month or a year’s worth of months, and each month’s array contains the boundary days from the previous and next month.

There’s plenty more we could do with our calendar beyond what we’ve covered here. Here are some ideas you could try on your own:

  • Allow the start of the week to be configurable
  • Display the week number at the start of each week
  • Create a week-specific view
  • Associate and display tasks for each day view
Get our latest insights in your inbox:

By submitting this form, you acknowledge our Privacy Notice.

Hey, let’s talk.
©2024 Tighten Co.
· Privacy Policy