Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,20 @@

All notable changes to this project will be documented in this file.

## 8.4.6 - Unreleased

### Fixed
- Date math on a `Hijri` instance follows the Hijri calendar. Before, it used Gregorian month lengths on the Hijri numbers: from 29 Safar 1445 (`2023-09-15`), `addDay()` gave 2 Rabi' Al-Awwal instead of 1, and `addMonth()` gave 1 Rabi' Al-Akher instead of 29 Rabi' Al-Awwal. Now days and smaller units move the real date, and months, quarters, years, decades and centuries are Hijri ones (`addMonths()`, `subYears()`, `add()`, `sub()`, `CarbonInterval`, `DateInterval`, `CarbonPeriod`), with Carbon's month overflow settings. `setDate()`, `setDateTime()`, `year()`, `month()`, `day()` and the `year`, `month` and `day` properties set Hijri values. `startOf`/`endOf` month, quarter, year, decade, century and millennium, `firstOfMonth()`, `lastOfMonth()` and `nthOfMonth()` (and their quarter and year versions) use Hijri boundaries. `daysInMonth`, `dayOfYear`, `daysInYear`, `isLeapYear()`, `quarter`, `diffInMonths()`, `diffInYears()`, `age`, `isSameMonth()`, `isSameYear()`, `isSameQuarter()` and `isBirthday()` are Hijri ones.
- Comparing a `Hijri` instance with other dates is right. The instance used to store the Hijri date as a Gregorian one (year 1445 AD), so `getTimestamp()`, `eq()`, `lt()`, `between()`, `isPast()`, `diffInDays()` and `diffForHumans()` were about 579 years off: `Hijri::fromGregorian('2024-03-11')->diffInDays(Carbon::parse('2024-03-11'))` returned `211302`. It now holds the real date and time, and reads it in the Hijri calendar with the adjustment it was converted with.
- `Hijri::now()`, `Hijri::create()`, `Hijri::createFromFormat()` and other Carbon factories return Hijri dates with Hijri month names. They take Gregorian input, like `Hijri::parse()`.
- `Hijri::fromGregorian()` and `toHijri()` keep the exact moment of a `DateTimeInterface`, and a `Hijri` instance passed to them is no longer converted twice.
- `serialize()` keeps the date, timezone and adjustment of a `Hijri` instance.

### Changed
- `getTimestamp()`, `rawFormat()`, `toISOString()`, `toJSON()`, `json_encode()` and the ATOM, RFC and W3C strings give the Gregorian date. `format()`, `isoFormat()`, `toDateString()`, `toDateTimeString()`, `toFormattedDateString()`, `toDayDateTimeString()`, `toArray()` and `(string)` still print the Hijri date. Pass `toGregorian()` to code that reads the date with `format()`.
- `toGregorian()` returns the date of the instance, also after date math. It no longer converts the changed values as a Hijri date, and no longer throws `InvalidHijriDateException`. `toImmutable()` returns the Gregorian date as a `CarbonImmutable`.
- Removed internal members of `Hijri`: the `$CURRENT_DAY`, `$hijriYear`, `$hijriMonth`, `$hijriDay`, `$hijriConvertedAt` and `$gregorianInstant` properties, `isUnchangedHijri()`, and the `getTranslatedDayName()` override.

## 8.4.5 - 2026-10-07

### Added
Expand Down
3 changes: 3 additions & 0 deletions docs/landing/02-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ items:
- icon: switch
title: Hijri to Gregorian
text: Build a Gregorian Carbon date from Hijri parts with `Carbon::fromHijri(1445, 9, 1)`.
- icon: calendar
title: Hijri Date Math
text: "`addMonths()`, `startOfMonth()` and `diffInMonths()` follow Hijri months, and comparisons work with any date."
- icon: document
title: Hijri String Parsing
text: Parse `YYYY-MM-DD` Hijri strings with an optional time using `Carbon::parseHijri()`.
Expand Down
4 changes: 4 additions & 0 deletions docs/landing/04-faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ Yes. {package.name} is open source under the {package.license} license, so you c

It uses the tabular (arithmetic) Islamic calendar, the same 30-year cycle used by most software. Real-world dates can differ by a day or two from local moon sighting, so the package lets you shift results with a day adjustment.

## Can I do date math in the Hijri calendar?

Yes. A `Hijri` instance holds the real date, so it compares with any other date, and `addMonths()`, `addYears()`, `startOfMonth()`, `endOfYear()` and `diffInMonths()` follow Hijri month lengths. Call `toGregorian()` to get the Gregorian date back.

## How do I show month names in Arabic?

Set an Arabic locale, either globally with `Carbon::setLocale('ar')` or per date with `->locale('ar')`. Any `ar` locale uses the Arabic month names; every other locale uses the English transliteration.
Expand Down
5 changes: 4 additions & 1 deletion docs/sections/01-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,13 @@ Hijri (Islamic) calendar support for PHP, built on [Carbon](https://carbon.nesbo
### Hijri to Gregorian {icon="switch"}
`Carbon::fromHijri()` and `Carbon::parseHijri()` return regular Carbon dates.

### Hijri Date Math {icon="calendar"}
`addMonths()`, `startOfMonth()`, `diffInMonths()` and friends follow Hijri months and years.

### Localized Months {icon="translate"}
Arabic month names for `ar` locales, English transliteration for the rest.
:::

:::info Quick Tip
The `Hijri` class extends `Carbon\Carbon`, so `format()`, `isoFormat()`, `locale()` and the `year`/`month`/`day` properties all work on a converted date.
The `Hijri` class extends `Carbon\Carbon` and holds the real date, so it compares with any other date, and `format()`, `isoFormat()`, the `year`/`month`/`day` properties and date math all work in Hijri.
:::
9 changes: 9 additions & 0 deletions docs/sections/04-basic-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,15 @@ $hijri->monthName; // "Ramadan"
$hijri->dayName; // "Monday"
```

Date math follows the Hijri calendar, and the instance still compares with any other date:

```php
$hijri->copy()->addMonth()->format('Y-m-d'); // "1445-10-01"
$hijri->copy()->endOfMonth()->format('Y-m-d'); // "1445-09-30"
$hijri->diffInDays(Carbon::parse('2024-03-21 09:30')); // 10
$hijri->toGregorian()->toDateTimeString(); // "2024-03-11 09:30:00"
```

Go the other way with `fromHijri()`, which returns a regular Gregorian `Carbon` instance:

```php
Expand Down
6 changes: 2 additions & 4 deletions docs/sections/06-hijri-to-gregorian.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Both methods are also available statically on the `Hijri` class without the mixi

### From a Hijri Instance

`toGregorian()` turns a `Hijri` instance back into a Gregorian `Carbon\Carbon`. It returns the exact date, time and timezone the instance was converted from, and remembers the adjustment used for that conversion:
`toGregorian()` turns a `Hijri` instance back into a Gregorian `Carbon\Carbon` with the same date, time and timezone. The instance keeps the adjustment it was converted with, so the result is the original date:

```php title="index.php"
use Pharaonic\Hijri\Hijri;
Expand All @@ -42,9 +42,7 @@ $hijri->format('Y-m-d'); // "1445-09-03"
$hijri->toGregorian()->format('Y-m-d H:i e'); // "2024-03-11 20:15 Asia/Riyadh"
```

Prefer it over `Hijri::fromHijri($hijri->year, $hijri->month, $hijri->day)`, which uses the global adjustment and drops the time.

If the instance was changed after the conversion (`addDay()`, `setTime()`...), its current year, month and day are converted as a Hijri date with the same adjustment, and its time and timezone are kept. That throws `InvalidHijriDateException` if the changed date doesn't exist in the Hijri calendar.
This also works after date math on the instance (`addMonth()`, `startOfYear()`...). Prefer it over `Hijri::fromHijri($hijri->year, $hijri->month, $hijri->day)`, which uses the global adjustment and drops the time.

### Invalid Input

Expand Down
101 changes: 101 additions & 0 deletions docs/sections/07-date-math.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
## Date Math & Comparisons

A `Hijri` instance holds the real date and time, and reads it in the Hijri calendar. Its timestamp, comparisons and day-based differences work with any other date, and month and year math follows Hijri month lengths.

### Adding and Subtracting

Days, weeks, hours and smaller units move the real date. Months, quarters, years, decades and centuries are Hijri ones:

```php title="index.php"
use Pharaonic\Hijri\Hijri;

$date = Hijri::fromGregorian('2023-09-15'); // 29 Safar 1445

$date->copy()->addDay()->format('Y-m-d'); // "1445-03-01"
$date->copy()->addMonth()->format('Y-m-d'); // "1445-03-29"
$date->copy()->subMonth()->format('Y-m-d'); // "1445-01-29"
$date->copy()->addYear()->format('Y-m-d'); // "1446-02-29"
```

`add()`, `sub()`, `CarbonInterval`, `DateInterval` and `CarbonPeriod` work the same way:

```php
use Carbon\CarbonInterval;

$date->copy()->add(CarbonInterval::months(2))->format('Y-m-d'); // "1445-04-29"
$date->copy()->add(new DateInterval('P1M2D'))->format('Y-m-d'); // "1445-04-01"
```

Like Carbon, a `Hijri` instance is mutable: use `copy()` to keep the original.

### Month Overflow

When the day doesn't exist in the target month (the 30th, in a 29-day month), the same Carbon settings apply. By default the date moves into the next month; the `NoOverflow` methods stop at the last day:

```php
$date = Hijri::fromGregorian('2023-08-17'); // 30 Muharram 1445, Safar has 29 days

$date->copy()->addMonth()->format('Y-m-d'); // "1445-03-01"
$date->copy()->addMonthNoOverflow()->format('Y-m-d'); // "1445-02-29"
```

### Setting Hijri Parts

`setDate()`, `year()`, `month()`, `day()` and the `year`, `month` and `day` properties take Hijri values and keep the time:

```php
$date = Hijri::fromGregorian('2024-03-11 09:15'); // 1 Ramadan 1445

$date->copy()->setDate(1446, 1, 10)->format('Y-m-d H:i'); // "1446-01-10 09:15"
$date->copy()->day(27)->format('Y-m-d'); // "1445-09-27"
$date->copy()->month(12)->day(10)->toGregorian()->toDateString(); // "2024-06-17"
```

A day out of range moves the date, like `DateTime::setDate()`: `setDate(1445, 2, 30)` is 1 Rabi' Al-Awwal, as Safar 1445 has 29 days.

### Start and End of Units

`startOfMonth()`, `endOfMonth()`, `startOfYear()`, `endOfYear()`, the quarter, decade and century versions, and `firstOfMonth()`, `lastOfMonth()` and `nthOfMonth()` use Hijri months and years:

```php
use Carbon\Carbon;

$date = Hijri::fromGregorian('2024-03-21'); // 11 Ramadan 1445

$date->copy()->startOfMonth()->toGregorian()->toDateString(); // "2024-03-11"
$date->copy()->endOfYear()->format('Y-m-d H:i:s'); // "1445-12-30 23:59:59"
$date->copy()->lastOfMonth(Carbon::FRIDAY)->format('Y-m-d'); // "1445-09-26"
```

### Comparing With Other Dates

Comparisons use the real date, so a `Hijri` instance can be compared with any Carbon or `DateTime` date:

```php
$date = Hijri::fromGregorian('2024-03-11');

$date->eq(Carbon::parse('2024-03-11')); // true
$date->diffInDays(Carbon::parse('2024-03-21')); // 10
$date->isPast(); // true
$date->getTimestamp(); // 1710115200 (2024-03-11 00:00 UTC)
```

`isSameMonth()`, `isSameYear()`, `isSameQuarter()`, `isCurrentMonth()` and `isBirthday()` compare Hijri months and years. `diffInMonths()`, `diffInQuarters()`, `diffInYears()` and `age` count Hijri months and years:

```php
$date->isSameMonth(Carbon::parse('2024-04-09')); // true, 30 Ramadan 1445
$date->diffInMonths(Carbon::parse('2024-04-10')); // 1, 1 Shawwal 1445
Hijri::fromGregorian('1990-01-01')->age; // age in Hijri years
```

`diffForHumans()` describes the real time span, with Carbon's (Gregorian) months and years.

### What Stays Gregorian

- `create()`, `createFromDate()`, `createFromFormat()` and `parse()` take Gregorian input, like `fromGregorian()`.
- `modify()` with a relative string (`'+1 month'`) uses PHP's Gregorian rules. Use `addMonths()` instead.
- `rawFormat()`, `toISOString()`, `toJSON()`, `json_encode()` and the RFC/ATOM strings give the Gregorian date, so APIs and other systems receive a standard date.
- `getTimestamp()` and the `U` format character are the real timestamp.
- ISO week values (`W`, `o`, `isoWeek`) are Gregorian ISO weeks.

To hand the date to code that expects a Gregorian date, use `toGregorian()` (or `toImmutable()` for a `CarbonImmutable`).
14 changes: 12 additions & 2 deletions docs/sections/07-formatting.md → docs/sections/08-formatting.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,14 +63,24 @@ $hijri->format('D, M j'); // "Mon, Ramadan 1"
| `Y`, `y` | Hijri year, four and two digits |
| `F`, `M` | Localized Hijri month name (both print the full name) |
| `l`, `D` | Localized weekday name, full and short |
| `w`, `N` | Weekday number of the original date (`0`–`6` from Sunday, `1`–`7` from Monday) |
| `w`, `N` | Weekday number (`0`–`6` from Sunday, `1`–`7` from Monday) |
| `t` | Number of days in the Hijri month (29 or 30) |
| `z` | Day of the Hijri year, starting from `0` |
| `L` | `1` if the Hijri year has 355 days, `0` otherwise |
| `S` | English ordinal suffix of the Hijri day (`st`, `nd`, `rd`, `th`) |
| `c`, `r` | ISO 8601 and RFC 2822 dates built from the Hijri values |

Time and timezone characters (`H`, `i`, `s`, `A`, `e`, `P`...) are unchanged. Escape a character with a backslash to print it as is: `format('\Y Y')` prints `"Y 1445"`.
Time and timezone characters (`H`, `i`, `s`, `A`, `e`, `P`...), the timestamp (`U`) and ISO weeks (`W`, `o`) are unchanged. Escape a character with a backslash to print it as is: `format('\Y Y')` prints `"Y 1445"`.

`rawFormat()` formats the Gregorian date. `toDateString()`, `toDateTimeString()`, `toFormattedDateString()` and `(string)` print the Hijri date, while `toISOString()`, `toJSON()` and `json_encode()` give the Gregorian ISO-8601 date, so APIs return a standard date:

```php
$hijri = Carbon::parse('2024-03-11 09:30', 'UTC')->toHijri();

$hijri->toDateString(); // "1445-09-01"
$hijri->rawFormat('Y-m-d'); // "2024-03-11"
json_encode($hijri); // "\"2024-03-11T09:30:00.000000Z\""
```

:::info Tip
Prefer `isoFormat()` for non-English output. It reads every name and pattern (`LL`, `LLLL`) from the locale's translations.
Expand Down
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,19 @@ Added to `Carbon\Carbon` by `Carbon::mixin(HijriCarbon::class)`, and available o
| --- | --- | --- |
| `Hijri::parse($time = null, $tz = null)` | Convert any Carbon-supported input to Hijri, using the global adjustment. | `Hijri` |
| `Hijri::fromGregorian($time = null, $tz = null, ?int $adjustment = null)` | Same as `parse()`, with a per-call adjustment. | `Hijri` |
| `toGregorian()` | The Gregorian date the instance was converted from, with its time, timezone and adjustment. See [Hijri to Gregorian](#hijri-to-gregorian). | `Carbon` |
| `toGregorian()` | The same date, time and timezone as a Gregorian date. See [Hijri to Gregorian](#hijri-to-gregorian). | `Carbon` |
| `toImmutable()` | The same, as a `CarbonImmutable`. | `CarbonImmutable` |
| `setDate($year, $month, $day)` | Set the Hijri date, keeping the time. A day or month out of range moves the date. | `Hijri` |
| `addMonths()`, `addYears()`, `add()`, `sub()`... | Months, quarters, years, decades and centuries are Hijri ones; days and smaller units move the real date. See [Date Math & Comparisons](#date-math). | `Hijri` |
| `startOfMonth()`, `endOfYear()`, `firstOfMonth()`, `nthOfMonth()`... | Hijri month, quarter, year, decade and century boundaries. | `Hijri` |
| `diffInMonths()`, `diffInYears()` | Difference in Hijri months and years (`int` with Carbon 2, `float` with Carbon 3). | `int` \| `float` |
| `isSameMonth()`, `isSameYear()`, `isBirthday()` | Compare Hijri months, years, and month and day. | `bool` |
| `Hijri::getInstance()` | The shared instance used to read and set the global adjustment. | `Hijri` |
| `locale(?string $locale = null, ...$fallbackLocales)` | Set the locale and switch month names between Arabic and English. Returns the locale when called with no argument. | `Hijri` \| `string` |
| `format($format)` | Carbon's `format()` with Hijri date values and localized Hijri month and day names. See [Formatting & Locales](#formatting). | `string` |
| `getTranslatedDayName($context = null, $keySuffix = '', $defaultValue = null)` | Localized weekday name of the original date. | `string` |
| `rawFormat($format)` | PHP's `format()` of the Gregorian date. | `string` |
| `toDateString()`, `toDateTimeString()`, `(string)` | The Hijri date (and time). | `string` |
| `toISOString()`, `toJSON()`, `json_encode()` | The Gregorian ISO-8601 date, for APIs. | `string` |

### Hijri Properties

Expand All @@ -37,11 +45,16 @@ The usual Carbon properties, read with Hijri values:
| `day` | Hijri day of month | `1` |
| `monthName` | Localized Hijri month name | `"Ramadan"` |
| `dayName` | Localized weekday name | `"Monday"` |
| `dayOfWeek` | Weekday of the original date, `0` (Sunday) to `6` (Saturday) | `1` |
| `dayOfWeekIso` | Weekday of the original date, `1` (Monday) to `7` (Sunday) | `1` |
| `hour`, `minute`, `second` | Unchanged from the source date | `9` |
| `daysInMonth` | Days in the Hijri month (29 or 30) | `30` |
| `dayOfYear` | Day of the Hijri year, from `1` | `237` |
| `daysInYear` | Days in the Hijri year (354 or 355) | `355` |
| `quarter`, `decade`, `century` | Built from the Hijri month and year | `3` |
| `dayOfWeek` | Weekday, `0` (Sunday) to `6` (Saturday) | `1` |
| `dayOfWeekIso` | Weekday, `1` (Monday) to `7` (Sunday) | `1` |
| `hour`, `minute`, `second` | The time of the date | `9` |
| `timestamp` | The Unix timestamp of the date | `1710115200` |

Weekday checks such as `isMonday()`, `isWeekday()` and `isWeekend()` follow the original date too.
Setting `year`, `month` or `day` sets the Hijri value. Weekday checks such as `isMonday()`, `isWeekday()` and `isWeekend()` work as in Carbon.

### HijriCalendar

Expand All @@ -57,4 +70,4 @@ All methods are static on `Pharaonic\Hijri\Calendar\HijriCalendar`.

| Class | Thrown by | When |
| --- | --- | --- |
| `Pharaonic\Hijri\Exception\InvalidHijriDateException` | `fromHijri()`, `parseHijri()`, `toGregorian()` of a changed instance | The Hijri date doesn't exist, or the string isn't `YYYY-MM-DD[ HH:MM[:SS]]`. |
| `Pharaonic\Hijri\Exception\InvalidHijriDateException` | `fromHijri()`, `parseHijri()` | The Hijri date doesn't exist, or the string isn't `YYYY-MM-DD[ HH:MM[:SS]]`. |
20 changes: 19 additions & 1 deletion docs/sections/10-examples.md → docs/sections/11-examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,25 @@ echo ' / ';
echo $hijri->isoFormat('D MMMM YYYY'); // "1 Ramadan 1445"
```

### 5. Dual-Calendar Date Helper
### 5. Monthly Renewals on the Hijri Calendar

Renew a subscription on the same Hijri day each month. `addMonthsNoOverflow()` stops at the 29th when the next month is shorter, and `toGregorian()` gives the date to store:

```php title="src/Renewals.php"
use Carbon\Carbon;

$start = Carbon::parse('2024-03-11')->toHijri(); // 1 Ramadan 1445

$renewals = [];

for ($month = 1; $month <= 3; $month++) {
$renewals[] = $start->copy()->addMonthsNoOverflow($month)->toGregorian()->toDateString();
}

// ["2024-04-10", "2024-05-09", "2024-06-08"]
```

### 6. Dual-Calendar Date Helper

A small helper class that formats a stored Gregorian timestamp in both calendars, used from a plain PHP template. The Gregorian side is numeric on purpose (see [Troubleshooting](#troubleshooting)):

Expand Down
Loading
Loading