Calculate business days

Viewed 146642

I need a method for adding "business days" in PHP. For example, Friday 12/5 + 3 business days = Wednesday 12/10.

At a minimum I need the code to understand weekends, but ideally it should account for US federal holidays as well. I'm sure I could come up with a solution by brute force if necessary, but I'm hoping there's a more elegant approach out there. Anyone?

Thanks.

33 Answers

Here's a function from the user comments on the date() function page in the PHP manual. It's an improvement of an earlier function in the comments that adds support for leap years.

Enter the starting and ending dates, along with an array of any holidays that might be in between, and it returns the working days as an integer:

<?php
//The function returns the no. of business days between two dates and it skips the holidays
function getWorkingDays($startDate,$endDate,$holidays){
    // do strtotime calculations just once
    $endDate = strtotime($endDate);
    $startDate = strtotime($startDate);


    //The total number of days between the two dates. We compute the no. of seconds and divide it to 60*60*24
    //We add one to inlude both dates in the interval.
    $days = ($endDate - $startDate) / 86400 + 1;

    $no_full_weeks = floor($days / 7);
    $no_remaining_days = fmod($days, 7);

    //It will return 1 if it's Monday,.. ,7 for Sunday
    $the_first_day_of_week = date("N", $startDate);
    $the_last_day_of_week = date("N", $endDate);

    //---->The two can be equal in leap years when february has 29 days, the equal sign is added here
    //In the first case the whole interval is within a week, in the second case the interval falls in two weeks.
    if ($the_first_day_of_week <= $the_last_day_of_week) {
        if ($the_first_day_of_week <= 6 && 6 <= $the_last_day_of_week) $no_remaining_days--;
        if ($the_first_day_of_week <= 7 && 7 <= $the_last_day_of_week) $no_remaining_days--;
    }
    else {
        // (edit by Tokes to fix an edge case where the start day was a Sunday
        // and the end day was NOT a Saturday)

        // the day of the week for start is later than the day of the week for end
        if ($the_first_day_of_week == 7) {
            // if the start date is a Sunday, then we definitely subtract 1 day
            $no_remaining_days--;

            if ($the_last_day_of_week == 6) {
                // if the end date is a Saturday, then we subtract another day
                $no_remaining_days--;
            }
        }
        else {
            // the start date was a Saturday (or earlier), and the end date was (Mon..Fri)
            // so we skip an entire weekend and subtract 2 days
            $no_remaining_days -= 2;
        }
    }

    //The no. of business days is: (number of weeks between the two dates) * (5 working days) + the remainder
//---->february in none leap years gave a remainder of 0 but still calculated weekends between first and last day, this is one way to fix it
   $workingDays = $no_full_weeks * 5;
    if ($no_remaining_days > 0 )
    {
      $workingDays += $no_remaining_days;
    }

    //We subtract the holidays
    foreach($holidays as $holiday){
        $time_stamp=strtotime($holiday);
        //If the holiday doesn't fall in weekend
        if ($startDate <= $time_stamp && $time_stamp <= $endDate && date("N",$time_stamp) != 6 && date("N",$time_stamp) != 7)
            $workingDays--;
    }

    return $workingDays;
}

//Example:

$holidays=array("2008-12-25","2008-12-26","2009-01-01");

echo getWorkingDays("2008-12-22","2009-01-02",$holidays)
// => will return 7
?>

There are some args for the date() function that should help. If you check date("w") it will give you a number for the day of the week, from 0 for Sunday through 6 for Saturday. So.. maybe something like..

$busDays = 3;
$day = date("w");
if( $day > 2 && $day <= 5 ) { /* if between Wed and Fri */
  $day += 2; /* add 2 more days for weekend */
}
$day += $busDays;

This is just a rough example of one possibility..

For holidays, make an array of days in some format that date() can produce. Example:

// I know, these aren't holidays
$holidays = array(
    'Jan 2',
    'Feb 3',
    'Mar 5',
    'Apr 7',
    // ...
);

Then use the in_array() and date() functions to check if the timestamp represents a holiday:

$day_of_year = date('M j', $timestamp);
$is_holiday = in_array($day_of_year, $holidays);

Here is a recursive solution. It can easily be modified to only keep track of and return the latest date.

//  Returns a $numBusDays-sized array of all business dates, 
//  starting from and including $currentDate. 
//  Any date in $holidays will be skipped over.

function getWorkingDays($currentDate, $numBusDays, $holidays = array(), 
  $resultDates = array())
{
  //  exit when we have collected the required number of business days
  if ($numBusDays === 0) {
    return $resultDates;
  }

  //  add current date to return array, if not a weekend or holiday
  $date = date("w", strtotime($currentDate));
  if ( $date != 0  &&  $date != 6  &&  !in_array($currentDate, $holidays) ) {
    $resultDates[] = $currentDate;
    $numBusDays -= 1;
  }

  //  set up the next date to test
  $currentDate = new DateTime("$currentDate + 1 day");
  $currentDate = $currentDate->format('Y-m-d');

  return getWorkingDays($currentDate, $numBusDays, $holidays, $resultDates);
}

//  test
$days = getWorkingDays('2008-12-05', 4);
print_r($days);

The add_business_days has a small bug. Try the following with the existing function and the output will be a Saturday.

Startdate = Friday Business days to add = 1 Holidays array = Add date for the following Monday.

I have fixed that in my function below.

function add_business_days($startdate, $buisnessdays, $holidays = array(), $dateformat = 'Y-m-d'){
$i= 1;
$dayx= strtotime($startdate);
$buisnessdays= ceil($buisnessdays);

while($i < $buisnessdays)
{
    $day= date('N',$dayx);

    $date= date('Y-m-d',$dayx);
    if($day < 6 && !in_array($date,$holidays))
        $i++;

    $dayx= strtotime($date.' +1 day');
}

## If the calculated day falls on a weekend or is a holiday, then add days to the next business day
$day= date('N',$dayx);
$date= date('Y-m-d',$dayx);

while($day >= 6 || in_array($date,$holidays))
{
    $dayx= strtotime($date.' +1 day');
    $day= date('N',$dayx);
    $date= date('Y-m-d',$dayx);
}

return date($dateformat, $dayx);}

If you need to get how much time was between two dates you can use https://github.com/maximnara/business-days-counter. It's works simply but only with laravel now $diffInSeconds = $this->datesCounter->getDifferenceInSeconds(Carbon::create(2019, 1, 1), Carbon::now(), DateCounter::COUNTRY_FR);

It doesn't counts public holidays and weekends and you can set working interval, for example from 9 to 18 with launch hour or no.

Or if you need just weekends and you use Carbon you can use built in function:

$date1 = Carbon::create(2019, 1, 1)->endOfDay();
$date2 = $dt->copy()->startOfDay();
$diff = $date1->diffFiltered(CarbonInterval::minute(), function(Carbon $date) {
   return !$date->isWeekend();
}, $date2, true);

But it will foreach every minute in interval, for bit intervals it can take a while.

Although this question has over 35 answers at the time of writing, there is a better solution to this problem.

@flamingLogos provided an answer which is based on Mathematics and doesn't contain any loops. That solution provides a time complexity of Θ(1). However, the math behind it is pretty complex especially the leap year handling.

@Glavić provided a good solution that is minimal and elegant. But it doesn't preform the calculation in a constant time, so it could produce a denial of service (DOS) attack or at least timeout if used with large periods like 10 or 100 of years since it loops on 1 day interval.

So I propose a mathematical approach that have constant time and yet very readable.

The idea is to count how many days to have complete weeks.

<?php
function getWorkingHours($start_date, $end_date) {
    // validate input
    if(!validateDate($start_date) || !validateDate($end_date)) return ['error' => 'Invalid Date'];
    if($end_date < $start_date) return ['error' => 'End date must be greater than or equal Start date'];

    //We save timezone and switch to UTC to prevent issues
    $old_timezone = date_default_timezone_get();
    date_default_timezone_set("UTC");

    $startDate = strtotime($start_date);
    $endDate = strtotime($end_date);

    //The total number of days between the two dates. We compute the no. of seconds and divide it to 60*60*24
    //We add one to include both dates in the interval.
    $days = ($endDate - $startDate) / 86400 + 1;
    $no_full_weeks = ceil($days / 7);
    //we get only missing days count to complete full weeks
    //we take modulo 7 in case it was already full weeks
    $no_of_missing_days = (7 - ($days % 7)) % 7;

    $workingDays = $no_full_weeks * 5;
    //Next we remove the working days we added, this loop will have max of 6 iterations.
    for ($i = 1; $i <= $no_of_missing_days; $i++){
        if(date('N', $endDate + $i * 86400) < 6) $workingDays--;
    }

    $holidays = getHolidays(date('Y', $startDate), date('Y', $endDate));

    //We subtract the holidays
    foreach($holidays as $holiday){
        $time_stamp=strtotime($holiday);
        //If the holiday doesn't fall in weekend
        if ($startDate <= $time_stamp && $time_stamp <= $endDate && date("N",$time_stamp) != 6 && date("N",$time_stamp) != 7)
            $workingDays--;
    }

    date_default_timezone_set($old_timezone);
    return ['days' => $workingDays];

}

The input to the function are in the format of Y-m-d in php or yyyy-mm-dd in general date format.

The get holiday function will return an array of holiday dates starting from start year and until end year.

I stumbled upon another question that asked a similar question and started writing code that solved his problem, but then found this question. Since I already have the code, I figure it won't be of any harm to post it here, someone will probably find it useful. I believe it is better/more flexible than most answers to this question.

My class is available here: https://gist.github.com/jurchiks/62ee8c4267dde48a296a8c2ab18965df

And its usage is as follows:

use Cake\Chronos\Date;

$weekdayCalculator = new WeekdayCalculator();
$weekdayCalculator->addSpecialWorkday(new Date('2022-01-08')); // Override Saturday as a workday.
// Sunday stays a normal holiday as usual.
$weekdayCalculator->addSpecialHoliday(new Date('2022-01-10')); // Override the following Monday as a holiday.
// As a result, we've shifted the workday one day back.

var_dump(
    $weekdayCalculator->isWorkday(new Date('2022-01-08')), // Returns TRUE - we manually set this date as a workday.
    $weekdayCalculator->isHoliday(new Date('2022-01-09')), // Returns TRUE - normal holiday.
    $weekdayCalculator->getNextWorkday(new Date('2022-01-07')), // Returns Date(2022-01-08).
    $weekdayCalculator->getNextWorkday(new Date('2022-01-08')), // Returns Date(2022-01-11) - 9, 10 = holidays, 11 = normal workday.
    $weekdayCalculator->getNextHoliday(new Date('2022-01-08')), // Returns Date(2022-01-09).
    $weekdayCalculator->countDays(new Date('2022-01-01'), new Date('2022-02-01')), // Returns int(31) - January has 31 days, 02-01 is excluded.
    $weekdayCalculator->countWorkdays(new Date('2022-01-01'), new Date('2022-02-01')), // Returns int(21).
    $weekdayCalculator->countHolidays(new Date('2022-01-01'), new Date('2022-02-01')), // Returns int(10).
    $weekdayCalculator->getDays(new Date('2022-01-01'), new Date('2022-02-01')), // Returns a Generator instance for all days between specified dates.
    $weekdayCalculator->getWorkdays(new Date('2022-01-01'), new Date('2022-02-01')), // Returns a Generator instance for all workdays.
    $weekdayCalculator->getHolidays(new Date('2022-01-01'), new Date('2022-02-01')), // Returns a Generator instance for all holidays.
    iterator_to_array($weekdayCalculator->getWorkdays(new Date('2022-01-01'), new Date('2022-02-01'))), // Returns an array of 21 Date instances.
);

You will still have to write your own code to add your country's specific work/holidays, but there are examples of how to do that in other answers here, and besides, that is not quite in the scope of a class like this.

Yes, it is dependent on cakephp/chronos because it's a very nice immutable-first PHP DateTime alternative; I much prefer it to Carbon, which is mutable-first.

I just get my function working based on Bobbin and mcgrailm code, adding some things that worked perfect to me.

function add_business_days($startdate,$buisnessdays,$holidays,$dateformat){
    $enddate = strtotime($startdate);
    $day = date('N',$enddate);
    while($buisnessdays > 0){ // compatible with 1 businessday if I'll need it
        $enddate = strtotime(date('Y-m-d',$enddate).' +1 day');
        $day = date('N',$enddate);
        if($day < 6 && !in_array(date('Y-m-d',$enddate),$holidays))$buisnessdays--;
    }
    return date($dateformat,$enddate);
}

// as a parameter in in_array function we should use endate formated to 
// compare correctly with the holidays array.
Related