---
title: "Date Project"
description: "Derafu Date"
type: "docs"
category: "doc"
tags: [php]
authors: [Anonymous]
date: "2026-11-05"
last_update: "2026-11-05"
time_minutes: 1
draft: false
unlisted: false
url: "https://www.derafu.dev/docs/core/date"
---

# Derafu Date



---

## Introduction

Months, periods, working days, date parsing and translatable date messages in PHP, with immutable objects and no hidden clock

# Derafu: Date

[![GitHub](https://img.shields.io/badge/github-derafu%2Fdate-blue?logo=github)](https://github.com/derafu/date)
![GitHub last commit](https://img.shields.io/github/last-commit/derafu/date/main)
![CI Workflow](https://github.com/derafu/date/actions/workflows/ci.yml/badge.svg?branch=main&amp;event=push)
![GitHub code size in bytes](https://img.shields.io/github/languages/code-size/derafu/date)
![GitHub Issues](https://img.shields.io/github/issues-raw/derafu/date)
![Total Downloads](https://poser.pugx.org/derafu/date/downloads)
![Monthly Downloads](https://poser.pugx.org/derafu/date/d/monthly)

Calendar months, periods of any length, working days, strict date parsing and the words that describe a date, built on `DateTimeImmutable`.

Nothing in it changes a date that is given and nothing reads the current time by itself: what depends on &quot;now&quot; takes a [PSR-20 clock](clocks). The words that describe a date (`Monday`, `3 days ago`) are not written in a language: they are [translatable messages](formatting), so a language is added with a catalogue and without code.

## Install

```bash
composer require derafu/date
```

It requires PHP 8.5 or later, `derafu/translation` (the messages) and `psr/clock`. It has no other dependency.

## The classes

| Class | What it is for | Page |
| --- | --- | --- |
| `Month` | A month of the calendar (`2025-11`), also as the number `202511`. | [Months](months) |
| `Period` | A range of days: a month, a quarter, a year, or from the 15th to the 14th. | [Periods](periods) |
| `DateUnit` and `Weekday` | The units of time, to move a date, and the days of the week. | [Date Units](date-units) |
| `DateParser` | Read and validate dates, and the periods written as numbers. | [Parsing](parsing) |
| `DateCalculator` | Age, days and months between dates, weeks, lists of years. | [Calculations](calculations) |
| `WorkCalendar` | Working days, with the holidays that are given to it. | [Working Days](working-days) |
| `DateFormatter` | Describe dates, months and elapsed time as translatable messages. | [Formatting](formatting) |
| `SystemClock` and `FixedClock` | The current moment, and a moment that does not change. | [Clocks](clocks) |

The errors are in [Errors and Translations](errors).

## Example

```php
use Derafu\Date\Enum\DateUnit;
use Derafu\Date\Service\DateFormatter;
use Derafu\Date\ValueObject\Month;
use Derafu\Date\ValueObject\Period;
use Derafu\Date\ValueObject\WorkCalendar;

// A billing cycle that goes from the 15th to the 14th.
$cycle = Period::starting(&#039;2025-11-15&#039;, DateUnit::MONTH);   // 2025-11-15/2025-12-14
$next = $cycle-&gt;next();                                     // 2025-12-15/2026-01-14

// The tenth working day of a month, with the holidays of the country.
$calendar = new WorkCalendar([&#039;2025-11-01&#039;, &#039;2025-12-08&#039;]);
$day = $calendar-&gt;getNthWorkingDay(Month::of(2025, 12), 10);

// A message that is translated where the language is known.
$message = (new DateFormatter())-&gt;formatDate($day);
$message-&gt;trans($translator, &#039;es&#039;);   // Lunes, 15 de Diciembre del 2025
```




---

## Months

Month, an immutable calendar month that is written as AAAA-MM or as the number AAAAMM

# Months

```php
use Derafu\Date\ValueObject\Month;
```

A `Month` is a year and a month of that year. It does not change: moving it gives another month. It is written as text (`2025-11`) and, for what keeps months as numbers, as an integer (`202511`).

## Create

| Method | What it does |
| --- | --- |
| `Month::of($year, $month)` | From a year (1 to 9999) and a number (1 to 12). |
| `Month::fromString(&#039;2025-11&#039;)` | From a text written as `AAAA-MM`. |
| `Month::fromInt(202511)` | From a number written as `AAAAMM`. |
| `Month::tryFromInt(202511)` | The same, and `null` if it is not a month. |
| `Month::fromDate($date)` | The month of a date. |
| `Month::current($clock = null)` | The current month, from a [clock](clocks). |

Anything that is not a month is an `InvalidMonthException`: month 13, a year out of 1 to 9999, a text with a day (`2025-11-01`), a number that is not six digits.

## Ask

| Method | What it gives |
| --- | --- |
| `getYear()`, `getMonth()` | The parts. |
| `getFirstDay()`, `getLastDay()` | The first and the last day, at the start of the day (`DateTimeImmutable`). |
| `getDays()` | The number of days of the month (28 to 31). |
| `contains($date)` | Whether a date is in the month. |
| `toString()`, `toInt()` | `2025-11` and `202511`. A month is also `Stringable` and serializes to JSON as its text. |
| `toPeriod()` | The [period](periods) of the month. |

## Move and compare

```php
$month = Month::of(2025, 11);

$month-&gt;next();        // 2025-12
$month-&gt;next(3);       // 2026-02
$month-&gt;previous(11);  // 2024-12
$month-&gt;monthsUntil(Month::of(2026, 3));   // 4

$month-&gt;isBefore($other);
$month-&gt;isAfter($other);
$month-&gt;equals($other);
$month-&gt;compareTo($other);   // negative, zero or positive
```

`next()` and `previous()` take any number of steps (a negative number goes the other way). A result out of the years 1 to 9999 is an `InvalidMonthException`.




---

## Periods

Period, an immutable range of days that can be a month, a quarter, a semester, a year or any cycle

# Periods

```php
use Derafu\Date\ValueObject\Period;
```

A `Period` is a range of days, from a first day to a last day, **both included**. The time of the day is not part of it. It can be any range: a month, a quarter, a year, several months, or from the 15th of a month to the 14th of the next one.

It does not change: moving it gives another period. It is `Stringable` (`2025-11-15/2025-12-14`) and serializes to JSON as `{&quot;start&quot;: &quot;2025-11-15&quot;, &quot;end&quot;: &quot;2025-12-14&quot;}`.

## Create

| Method | What it does |
| --- | --- |
| `Period::between($start, $end)` | From a first day to a last day (dates or texts). |
| `Period::month($year, $month)` | A month. |
| `Period::fromMonth($month)` | The period of a [`Month`](months). |
| `Period::months($start, $end)` | From the first day of a month to the last day of another one. |
| `Period::quarter($year, $quarter)` | A quarter, from 1 to 4. |
| `Period::semester($year, $semester)` | A semester, 1 or 2. |
| `Period::year($year)` | A year. |
| `Period::starting($date, $unit, $count = 1)` | Some [units](date-units) from a date: it ends the day before the same date of the units that follow. |

```php
Period::starting(&#039;2025-11-15&#039;, DateUnit::MONTH);      // 2025-11-15/2025-12-14
Period::starting(&#039;2025-01-01&#039;, DateUnit::MONTH, 3);   // 2025-01-01/2025-03-31
Period::starting(&#039;2025-07-01&#039;, DateUnit::YEAR);       // 2025-07-01/2026-06-30
```

An end before the start, a quarter that is not 1 to 4, a semester that is not 1 or 2 and a number of units less than 1 are an `InvalidPeriodException`.

## Ask

| Method | What it gives |
| --- | --- |
| `getStart()`, `getEnd()` | The first and the last day, at the start of the day. |
| `getStartMonth()`, `getEndMonth()` | The [months](months) of both. |
| `getDays()` | The number of days. |
| `getMonths()`, `countMonths()` | The months that have days of the period, and how many. |
| `contains($dateOrPeriod)` | Whether a date, or a whole period, is inside. |
| `overlaps($period)`, `equals($period)` | Whether they share days, or are the same. |

## Alignment

`isAlignedTo($unit)` tells if the period starts at the beginning of a unit and ends at its end. A period from January 1 to June 30 is aligned to months, quarters and semesters, and it is not aligned to years. Every period is aligned to days, and a week starts on Monday.

## Move and split

```php
$quarter = Period::quarter(2025, 4);
$quarter-&gt;next();       // 2026-01-01/2026-03-31
$quarter-&gt;previous(2);  // 2025-04-01/2025-06-30

Period::semester(2025, 2)-&gt;split(DateUnit::QUARTER);
// 2025-07-01/2025-09-30 and 2025-10-01/2025-12-31
```

`next()` and `previous()` give the period that follows or precedes with the same length. A period that is a whole number of months from its first day (a month, a quarter, or from March 15 to April 14) moves by that number of months; any other one moves by the same number of days.

```php
Period::between(&#039;2025-03-15&#039;, &#039;2025-04-14&#039;)-&gt;next();   // 2025-04-15/2025-05-14
Period::between(&#039;2025-01-01&#039;, &#039;2025-01-10&#039;)-&gt;next();   // 2025-01-11/2025-01-20
```

`split($unit, $count = 1)` cuts the period in consecutive periods of that unit, counted from its first day. The last one ends where the period ends, so it can be shorter.

## The year

| Method | What it does |
| --- | --- |
| `isSingleYear()` | Whether it starts and ends in the same year. |
| `getYear()` | The year, or an `InvalidPeriodException` if it has days of more than one year. |
| `requireSingleYear()` | The same period, or an `InvalidPeriodException`. |
| `startsOnYearStart()` | Whether it starts on January 1. |
| `isBeforeStartInSameYear($date)` | Whether a date is before the start, in the year of the start. |




---

## Date Units

DateUnit, to move a date by days, weeks, months, quarters, semesters and years, and Weekday

# Date Units

```php
use Derafu\Date\Enum\DateUnit;
use Derafu\Date\Enum\Weekday;
```

## DateUnit

`DateUnit` is an enum of the units of time. The value of each case is its one-letter code.

| Case | Code | Months |
| --- | --- | --- |
| `DAY` | `D` | |
| `WEEK` | `W` | |
| `MONTH` | `M` | 1 |
| `QUARTER` | `Q` | 3 |
| `SEMESTER` | `S` | 6 |
| `YEAR` | `Y` | 12 |

`DateUnit::fromCode(&#039;m&#039;)` gives the unit of a code in upper or lower case, and an `InvalidDateException` if there is none. `getMonths()` gives the months of the unit, or `null` for days and weeks.

## Move a date

```php
DateUnit::MONTH-&gt;add($date, 1);        // a month later
DateUnit::QUARTER-&gt;subtract($date, 2); // two quarters earlier
DateUnit::DAY-&gt;add($date, -3);         // a negative number goes the other way
```

`add($date, $steps = 1)` and `subtract($date, $steps = 1)` give a new `DateTimeImmutable`; the date that is given does not change and its time is kept.

Months, quarters, semesters and years never overflow: the day that the month of the result does not have is the last day of that month.

| Date | Move | Result |
| --- | --- | --- |
| `2024-01-31` | + 1 month | `2024-02-29` |
| `2023-01-31` | + 1 month | `2023-02-28` |
| `2024-08-31` | + 1 semester | `2025-02-28` |
| `2024-02-29` | + 1 year | `2025-02-28` |

## Weekday

`Weekday` numbers the days of the week like ISO 8601: Monday is `1` and Sunday is `7`. `Weekday::fromDate($date)` gives the day of the week of a date. A [work calendar](working-days) uses it to say which days are the weekend.




---

## Parsing

Read and validate dates, strictly or like PHP does, and validate the periods written as numbers

# Parsing

```php
use Derafu\Date\Support\DateParser;
```

| Method | What it does |
| --- | --- |
| `DateParser::parse($text)` | Reads a date like PHP does. |
| `DateParser::parseStrict($text, $format = &#039;Y-m-d&#039;)` | Reads a date written exactly in a format. |
| `DateParser::ensure($dateOrText)` | A `DateTimeImmutable` from a date or a text. |
| `DateParser::isValid($text, $format = &#039;Y-m-d&#039;)` | Whether a text is a date written exactly in the format. |
| `DateParser::reformat($text, $format = &#039;d/m/Y&#039;)` | Writes a `Y-m-d` date in another format. |
| `DateParser::fromSpreadsheetSerial($number)` | The date of a spreadsheet serial number. |

## Lenient and strict

`parse()` is as lenient as `DateTimeImmutable`: it accepts relative texts (`tomorrow`), and a day that the month does not have is moved to the next month.

```php
DateParser::parse(&#039;2025-02-31&#039;);   // 2025-03-03
```

`parseStrict()` and `isValid()` read a date in a format and refuse what is not a date: it must exist and have every field of the format. They do not accept relative texts, a day that the month does not have or extra text.

```php
DateParser::parseStrict(&#039;15/01/2024&#039;, &#039;d/m/Y&#039;);   // 2024-01-15 00:00:00
DateParser::isValid(&#039;2025-02-31&#039;);                // false
DateParser::isValid(&#039;tomorrow&#039;);                  // false
```

They read the fields as PHP does, so the digits can be fewer than the format writes: `2025-1-5` is the date `2025-01-05`, and a year written `24` is the year 24. They are for reading; `-&gt;format(&#039;Y-m-d&#039;)` gives the date in its canonical form, and [`reformat()`](#reformat) refuses what is not written with all its digits.

`parseStrict()` gives the start of the day. `parse()` and `parseStrict()` throw an `InvalidDateException` when the text is not a date.

## Reformat

`reformat()` is for a date that must be exactly `Y-m-d`. It gives `null` if it is not one, and that includes a date written without the zeros (`2024-1-5`).

```php
DateParser::reformat(&#039;2023-03-12&#039;);            // 12/03/2023
DateParser::reformat(&#039;2023-03-12&#039;, &#039;Y.m.d&#039;);   // 2023.03.12
DateParser::reformat(&#039;12/03/2023&#039;);            // null
```

## Spreadsheet serial numbers

Spreadsheets number the days since 1900: `1` is January 1, 1900. They also count a day that never existed, February 29, 1900, which is the number `60` and gives February 28.

```php
DateParser::fromSpreadsheetSerial(45292);   // 2024-01-01
```

## Periods written as numbers

A period written as a number is a year (`2025`) or a year and a month (`202511`).

| Method | What it checks |
| --- | --- |
| `DateParser::isValidPeriod($period, $yearFrom = 2000, $yearTo = 2100, $length = null)` | A year or a month, within the years. With `$length` (4 or 6) it must have those digits. |
| `DateParser::isValidYearPeriod($period, ...)` | A year. |
| `DateParser::isValidMonthPeriod($period, ...)` | A year and a month. |

To work with the months, use [`Month::fromInt()`](months).




---

## Calculations

Age, days and months between dates, matching days, weeks and lists of years

# Calculations

```php
use Derafu\Date\Support\DateCalculator;
```

What depends on the current date takes a [clock](clocks) as its last argument, and uses the clock of the system if it is not given one. The dates are `DateTimeInterface` objects or texts.

| Method | What it gives |
| --- | --- |
| `DateCalculator::age($birth, $clock = null)` | The age in years. |
| `DateCalculator::daysBetween($from, $to = null, $clock = null)` | The complete days from a date to another one (negative if the second is before). Without `$to`, until the clock. |
| `DateCalculator::monthsBetween($from, $to = null, $clock = null)` | The months between the months of two dates, without looking at the days. A period written as `202511` is accepted instead of a date. |
| `DateCalculator::countMatchingDays($from, $to, $days, $excludeWeekend = false)` | How many days of a range are in a list of `Y-m-d` dates. |
| `DateCalculator::firstDayOfWeek($date = null, $clock = null)` | Monday of the week, at the start of the day. |
| `DateCalculator::lastDayOfWeek($date = null, $clock = null)` | Sunday of the week, at the end of the day. |
| `DateCalculator::years($total, $from = null, $clock = null)` | A list of years, from the most recent to the oldest. |

```php
DateCalculator::monthsBetween(202301, &#039;2024-01-15&#039;);   // 12
DateCalculator::monthsBetween(&#039;2024-01-15&#039;, &#039;2024-02-14&#039;);   // 1
DateCalculator::years(3, 2024);   // [2024, 2023, 2022]
```




---

## Working Days

WorkCalendar, an immutable calendar of working days with the weekend and the holidays that are given to it

# Working Days

```php
use Derafu\Date\ValueObject\WorkCalendar;
```

A `WorkCalendar` knows which days are not working days: the days of the week that are weekend and the holidays. It does not change: the `with*()` methods give another calendar.

The holidays depend on the country and the year, so the package does not bring them: whoever uses the calendar gives them.

```php
use Derafu\Date\Enum\Weekday;

$calendar = new WorkCalendar(
    holidays: [&#039;2025-01-01&#039;, &#039;2025-05-01&#039;],
    weekend: [Weekday::SATURDAY, Weekday::SUNDAY],   // the default
);
```

The holidays are dates or texts written as `Y-m-d` (another text is an `InvalidDateException`). They are kept sorted and without repeating.

## Ask

| Method | What it does |
| --- | --- |
| `isHoliday($date)` | Whether the date is a holiday. |
| `isWorkingDay($date)` | Whether it is not weekend and not a holiday. |
| `addWorkingDays($date, $days)` | The date some working days later (earlier, if negative). The time is kept. |
| `subtractWorkingDays($date, $days)` | The date some working days earlier. |
| `getWorkingDayNumber($date)` | The number of a working day in its month (the first is 1), or `null` if the date is not a working day. |
| `getNthWorkingDay($month, $number)` | The date of the working day of a [month](months) with a number, or `null` if the month does not have so many. A number less than 1 is an `InvalidDateException`. |
| `isLastWorkingDay($date)` | Whether it is the last working day of its month. |
| `countWorkingDays($from, $to)` | The working days of a range, both ends included. |
| `getHolidays()`, `getWeekend()` | What the calendar was given. |

```php
$calendar-&gt;addWorkingDays(&#039;2025-01-03&#039;, 1);               // 2025-01-06 (Monday)
$calendar-&gt;getNthWorkingDay(Month::of(2025, 3), 1);       // 2025-03-03 (March 1 is a Saturday)
$calendar-&gt;getWorkingDayNumber(&#039;2025-03-03&#039;);             // 1
```

## Other calendars

```php
$calendar-&gt;withHolidays([&#039;2026-01-01&#039;]);                      // other holidays, same weekend
$calendar-&gt;withWeekend([Weekday::FRIDAY, Weekday::SATURDAY]); // other weekend, same holidays
```




---

## Formatting

DateFormatter describes dates, months and elapsed time as translatable messages, so a language is added with a catalogue and no code

# Formatting

```php
use Derafu\Date\Service\DateFormatter;
```

`DateFormatter` describes a date, a month and the time that has passed. It does not give text: every method gives a [`TranslatableMessage`](https://www.derafu.dev/docs/core/translation), and the language is decided by whoever translates it.

| Method | Message |
| --- | --- |
| `formatDate($date, $includeWeekday = true)` | `Monday, January 15, 2024` |
| `formatMonth($month)` | `January 2024`. It takes a [`Month`](months) or a number written as `AAAAMM`. |
| `formatElapsed($date, $full = false)` | `3 days ago`, `1 year, 5 days ago`, `just now`. |

```php
$formatter = new DateFormatter();

$message = $formatter-&gt;formatDate(&#039;2024-01-15&#039;);

$message-&gt;trans($translator, &#039;es&#039;);   // Lunes, 15 de Enero del 2024
(string) $message;                    // Monday, January 15, 2024
```

Without a translator the message is written in English, which is the language of its id.

`formatElapsed()` describes the time between the date and the clock (the system clock, or the one that is given to the constructor). It gives the two largest units (`1 year, 5 days`), or all of them with `$full`: years, months, weeks, days, hours, minutes and seconds. A difference of nothing is `just now`.

## The catalogues

The names and phrases are in the translation domain `date`. The English text of each message is its id, and the catalogue of a language translates it:

```php
// resources/translations/date+intl-icu.es.php
return [
    &#039;Monday&#039; =&gt; &#039;Lunes&#039;,
    &#039;January&#039; =&gt; &#039;Enero&#039;,
    &#039;{weekday}, {month} {day}, {year}&#039; =&gt; &#039;{weekday}, {day} de {month} del {year}&#039;,
    &#039;{month} {year}&#039; =&gt; &#039;{month} de {year}&#039;,
    &#039;{count, plural, one {# day} other {# days}}&#039; =&gt; &#039;{count, plural, one {# día} other {# días}}&#039;,
    &#039;{parts} ago&#039; =&gt; &#039;hace {parts}&#039;,
    &#039;just now&#039; =&gt; &#039;recién&#039;,
];
```

The Spanish catalogue ships with the package. The order of the words, the plurals and the names are all in the catalogue, so another language is one more file (`date+intl-icu.fr.php`) and no code. The catalogue of a language must have every id of the domain `date`.

To have the catalogue in your translator, import the services of the package (see [Errors and Translations](errors)).




---

## Clocks

SystemClock and FixedClock, the PSR-20 clocks of the package

# Clocks

```php
use Derafu\Date\Service\FixedClock;
use Derafu\Date\Service\SystemClock;
```

Nothing in the package reads the current time by itself. What depends on &quot;now&quot; (`Month::current()`, the age, the days until today, `formatElapsed()`) takes a `Psr\Clock\ClockInterface`, and uses `SystemClock` if it is not given one.

| Class | What it gives |
| --- | --- |
| `SystemClock` | The current moment. |
| `FixedClock` | Always the same moment, given as a date or as a text. |

```php
$clock = new FixedClock(&#039;2024-01-15 12:00:00&#039;);

Month::current($clock);                            // 2024-01
DateCalculator::age(&#039;2004-01-16&#039;, $clock);         // 19
(new DateFormatter($clock))-&gt;formatElapsed(&#039;2024-01-15 11:45:00&#039;);   // 15 minutes ago
```

A `FixedClock` is for tests and for calculations that must be made as of a date. Any other PSR-20 clock works in their place.




---

## Errors and Translations

The exceptions of Derafu Date and how to translate their messages

# Errors and Translations

Every error of the package is a `Derafu\Date\Exception\DateException`, which extends `TranslatableInvalidArgumentException` of [`derafu/translation`](https://www.derafu.dev/docs/core/translation). Catching `DateException`, or PHP&#039;s `InvalidArgumentException`, catches all of them.

| Exception | When |
| --- | --- |
| `InvalidDateException` | A text is not a date, a date is not valid in its format, a time unit does not exist, or a number of the working day is less than 1. |
| `InvalidMonthException` | A month, a text `AAAA-MM` or a number `AAAAMM` is not a month, or a move leaves the years 1 to 9999. |
| `InvalidPeriodException` | The end of a period is before its start, a quarter or a semester does not exist, a number of units is less than 1, or a period must be in a single year and it is not. |

The last two extend `DateException`, like the first one.

```php
try {
    Month::fromInt(202513);
} catch (InvalidMonthException $e) {
    $e-&gt;getMessage();   // Invalid month period: 202513.
}
```

## Translations

The messages of the exceptions (domain `errors`) and the names and phrases of the dates (domain `date`) are translatable, and the Spanish translations ship with the package. To have them in your translator, import its services (or add `DateTranslationResourceProvider` to the resource providers of the translator):

```yaml
imports:
    - { resource: &#039;../vendor/derafu/date/resources/config/date-services.yaml&#039; }
```

The same file registers `DateFormatterInterface` and a `SystemClock` for `Psr\Clock\ClockInterface`.

With the translator, the message of the example above is `El período 202513 no es un mes, debe escribirse AAAAMM.`





---
Last updated on 05/11/2026
#php
