Skip to the guide
All guides

Upgrade guide

Changes that can affect your code before 1.0, with before and after examples and the steps to migrate, plus the highlights of the changelog for the 0.x series.

app RTLY-Kit 0.3.0checked reading 18 minutes

On this page

Before 1.0.0, a minor release may contain breaking changes. Each one is listed here with the steps to migrate. The policy is on API stability. This page starts with the newest version. The last section covers the early pre-release builds, from before 0.1.0.

From 0.2.1 to 0.3.0

The changes below can break code that worked before. Most of them turn a guessed or wrong result into an exception or an invalid Result. Each item gives the old behaviour, the new behaviour and what to do.

Dates from text

  • Text that only looks like a date. make() on Jalali, Hijri and Hebrew reads the exact shapes Y/m/d and Y-m-d (with an optional time and zone designator) as a date of the calendar. Before, text that started like such a date but had more or less was handed to PHP and read as a Gregorian date, which gave wrong results. Now it throws InvalidDateException. Examples are '1403-01-01 10:00 UTC', a time with PM, one-digit minutes and '1403-01'. What to do: pass a DateTimeImmutable for such values, or cut the text to one of the accepted shapes.
  • Bare digits. Before, a run of 3 to 8 digits such as '1403' or '14030101' was read by PHP as a clock time. Now it throws InvalidDateException. Eight digits that start with a year of another calendar, such as '20240101', are still read as a Gregorian date. A 5-digit year in a Jalali or Hijri string throws with the code date_out_of_range. What to do: use create() or a DateTimeImmutable.
  • Impossible Gregorian days. make() on the three calendar classes, and JalaliCast, throw InvalidDateException for a Gregorian string with a day that does not exist. Text with a negative year such as '-0100/01/01' throws a clear InvalidDateException too. For negative years, use create() or a timestamp.
CallBeforeAfter
Jalali::make('2024-02-30')the date of 2024-03-01InvalidDateException
  • createFromFormat() tokens. Jalali::createFromFormat() rejects the tokens z y F M D l S e T P O p u v with InvalidDateException, because they have a Gregorian or time-zone meaning and gave wrong dates. The format U alone reads a Unix timestamp. What to do: use the tokens d j m n Y H G i s g h A a, and pass the time zone as the third argument.
  • End of a period. endOfDay(), endOfMonth() and endOfYear() end at 23:59:59.999999 and not at 23:59:59.000000. A format with H:i:s prints the same text. A strict comparison with a value built at 23:59:59 can now give another result. What to do: compare against the new end value, or use startOfDay() of the next day.
  • Year tokens in format(). The token Y writes at least 4 digits, with a minus sign for negative years. The token y uses floor modulo.
CallBeforeAfter
Jalali::create(-5, 1, 1)->format('Y')-005-0005
Jalali::create(-620, 1, 1)->format('y')-2080

Eloquent cast

  • Unreadable stored values. When the model reads the column, JalaliCast throws InvalidDateException if the stored value is an array, a boolean, a float or an object. Before, it returned null. null and an empty string still give null. What to do: fix the column type, or catch the exception where you read the attribute.

Validators

  • Allowed characters. Mobile, PostalCode and BankCard accept only digits plus spaces, no-break spaces, half-spaces (ZWNJ), LRM and RLM marks, hyphens, parentheses and dots. Mobile also accepts one + at the start of a +98 number. Everything else gives invalid_format, and normalize() returns an empty string. Before, these classes kept only the digits and dropped the rest. What to do: clean the input on your side if you want to allow more.
InputBeforeAfter
Mobile::validate('call 09121234567 now')validinvalid_format
Mobile::validate('-9121234567')validinvalid_format
  • Tab, newline and NUL at the ends. These characters are no longer trimmed. NationalCode, Sheba, VehiclePlate, Format::withSeparator() and NumberToWords reject them. What to do: trim the input yourself when you want that.
  • Sheba check digits. Sheba gives invalid_checksum for the check digits 00, 01 and 99, even when the modulo comes out right. The standard produces only 02 to 98.
  • Postal codes. PostalCode gives invalid_format for a code with the digit 0 or 2 in its first five digits. The rule comes from three public sources. Iran Post publishes no official rule set. What to do: if you must accept codes that this rule rejects, check the shape yourself.
CallBeforeAfter
PostalCode::isValid('1234567890')truefalse
PostalCode::isValid('1593715416')truetrue
  • Sheba bank names. The names for the codes 013 and 019 are now بانک رفاه کارگران and بانک صادرات ایران, the same as in the BIN table. If you compare the names as text, update the comparison.

Numbers and text

  • Grouping marks. NumberToWords::convert() and Format::withSeparator() accept thousands separators only in proper grouping: a first group of 1 to 3 digits, then groups of exactly 3. What to do: remove stray spaces and commas from the input.
InputBeforeAfter
'1 234 567'12345671234567
'1,234,567'12345671234567
'12 34'1234InvalidNumberException (invalid_number)
'1 2'12InvalidNumberException (invalid_number)
  • Locale names. NumberToWords accepts a locale only when its language part is exactly fa or ar. arn and farsi were accepted by prefix. Now they throw UnsupportedLocaleException.
  • Invalid UTF-8. Normalizer::normalize(), fixHalfSpace() and clean() replace bytes that are not valid UTF-8 with U+FFFD and then apply every step. Before, they applied only the byte steps to such text. Detector ignores invalid bytes and no longer returns false or ltr because of them.
  • Locale direction. Detector::isRtlLocale() follows an explicit script subtag, so fa-Latn, ar-Latn and sd-Deva give false. Detector::direction() uses the same right-to-left scripts as containsRtl().

Prayer times

  • Input ranges. new PrayerTimes() throws InvalidPrayerConfigException for NaN, infinity, a latitude outside -90 to 90, a longitude outside -180 to 180 and an elevation beyond 20000 m in either direction. getTimes() and nextPrayer() throw InvalidDateException when the local year is outside 1 to 9999. Before, they returned wrong times.
  • Missing times. A prayer is null when its local date would fall after 9999-12-31 or before 0001-01-01. nextPrayer() returns null when no prayer is left up to the end of 9999. Near the poles, a time that would break the order fajr, sunrise, dhuhr, asr, maghrib, isha is null too. Asr after Isha at 89.9 degrees is an example. What to do: handle null in your output, as you already do for polar days.
  • Rounding. For time zones whose offset includes seconds (mostly dates before 1970), times are rounded to the nearest minute and not cut. A time can differ by one minute from before.

Holidays

  • Date strings. HolidayCalendar::withHoliday() and withoutHoliday() accept only a Jalali date string: Y/m/d or Y-m-d, with a 3 or 4 digit year. Persian and Arabic digits are allowed. A year of 1700 or later, a time part and free text throw InvalidDateException. Before, a Gregorian-looking string was read as another day. A Hijri month start that contains a NUL byte throws InvalidDateException.
CallBeforeAfter
withHoliday('2027/05/05', 'x')another day than intendedInvalidDateException
withHoliday('1406/02/03', 'x')worksworks
  • Laravel config. A rtly-kit.holidays value that is not an array throws InvalidDateException when the holiday calendar is resolved. Before, it was ignored. What to do: set the entry to an array, or remove it.

Also check

These changes are not breaking for most code, but they can show up in tests or stored values.

  • serialize() of Jalali, Hijri and Hebrew stores the moment (and the Hijri variant) as a small versioned array. Values serialized by earlier versions still load. If you compare or store the raw serialized string, you will see a different value.
  • Hijri::make() and Hebrew::make() with an instance of their own class now honour the time zone argument. Before, they ignored it. Hijri::make() takes ?HijriVariant $variant = null. The value null keeps the variant of a Hijri instance, and a variant you pass always applies.
  • Comparison methods and between() use the whole instant, microseconds included. addMonths() and addYears() keep the microseconds.
  • For the Tehran method with the OneSeventh and NightMiddle rules at high latitudes, Maghrib and Isha are no longer identical, so those times can change slightly. For zones far from their longitude, such as Pacific/Kiritimati, prayer times now belong to the requested local date.

From 0.2.0 to 0.2.1

The Composer package name changed to enaxon/rtly-kit. The namespace is still RtlyKit\, so your PHP code does not change. Remove the earlier package from your composer.json and require this one:

Bash
composer require enaxon/rtly-kit

From 0.1.x to 0.2.0

Most applications need no change. Check the items below. The items on official holiday dates, high-latitude prayer times, Mobile validation and Arabic number words change what the library returns for some inputs.

  1. ext-mbstring is no longer required. composer.json used to ask for the extension. Now it needs only PHP. There is nothing to do. If you added ext-mbstring to your own composer.json only for this package, you can remove it.
  2. Float formatting. Format::withSeparator() and format_number() now print a float as the shortest decimal that reads back as the same float, cut to 15 significant digits. Binary noise is gone. Strings are unchanged and stay exact. Pass a string when you need more digits.
CallBeforeAfter
withSeparator(-1234567.891)-۱٬۲۳۴٬۵۶۷٫۸۹۱۰۰۰۰۰۰۰۶۱۴۶۷-۱٬۲۳۴٬۵۶۷٫۸۹۱
withSeparator(0.1 + 0.2)۰٫۳۰٫۳ (unchanged)
withSeparator(-0.0)۰۰

If you format the result of float arithmetic, round first (round($x, 2)) or pass a string.

  1. NumberToWords::fromWords() is strict. It used to add the words up in any order.
InputBeforeAfter
دو صد102InvalidNumberException (invalid_number_words)
بیست یک21InvalidNumberException
صد و بیست و120InvalidNumberException
پنج و بیست25InvalidNumberException
سی و پنج3535

Write و between parts and keep the order hundreds, tens, units. Everything convert() returns still parses.

  1. Slugify::make() throws RtlyKitException ( invalid_argument , argument = text ) for text that is not valid UTF-8. Before, only the separator was checked. Catch RtlyKitThrowable or clean the input first.
  2. Hijri::format() and Hebrew::format() reject a pattern longer than 256 bytes with InvalidDateException ( input_too_long , argument = format , limit = 256). Jalali::format() already did. The limit is in Hijri::MAX_FORMAT_LENGTH and Hebrew::MAX_FORMAT_LENGTH .
  3. A trailing backslash in a format pattern is dropped in all three calendars. format('Y\') returns just the year.
  4. IranHolidays::all(), allTitles() and allFixed() work for every Jalali year from -620 to 9377. Before, years before the Hijri epoch and the last day of 9377 threw invalid_date . Where Islamic holidays cannot be derived, only the fixed holidays are returned. For a year outside the range they throw InvalidDateException ( date_out_of_range , context year , min , max ).
  5. Error messages that repeat your input cut it to 40 characters and stay valid UTF-8. If you match on message text, match on getErrorCode() instead.
  6. Official holiday dates for 1380 to 1405. In these Jalali years the static methods ( IranHolidays , is_iran_holiday() ) now return the published dates instead of the Umm al-Qura estimate. Other years are unchanged.
DayBefore (estimate)After (official)
1404/01/10عید فطرno holiday
1404/01/11تعطیل عید فطرعید فطر
1404/01/01جشن نوروز + شهادت امام علیجشن نوروز
1404/12/29ملی شدن صنعت نفت + عید فطرملی شدن صنعت نفت
1405/01/01جشن نوروز + تعطیل عید فطرجشن نوروز + عید فطر

In official years only the titles in the official table appear. Years 1380 to 1393 and 1395 are reported: the dates come from one source, and the list of days may be incomplete. To get the old behaviour for all years, use HolidayCalendar::default()->withOfficialData(false). To see where a year comes from, use IranHolidays::sourceOf($year). More in Holiday calendar.

  1. High-latitude prayer times. PrayerTimes has a new HighLatitudeRule . The default is AngleBased . It acts only where a Fajr or Isha time would be missing, or farther from sunrise or sunset than the rule allows. Below about 44 degrees north nothing changes. North of about 44 to 46 degrees, around the June solstice, some values differ. A time that was null now has a value.
Place and day (MWL)Before (same as None)After (default)
Stockholm, 2026-06-21Fajr null, Isha nullFajr 01:54, Isha 23:40
Munich, 2026-06-21Fajr 01:50, Isha 00:12Fajr 02:51, Isha 23:32

To get the old output, call $prayer->withHighLatitudeRule(HighLatitudeRule::None). nextPrayer() now also finds a prayer that falls after midnight. See High latitudes.

  1. Mobile validation. The shape check is the same, so no input changes validity. Two things are new. Every result has an allocated detail and Mobile::isAllocated() returns it as a bool. The operator table follows the numbering plan more closely: the Shatel Mobile key 998 is narrowed to 09981 and 09982 , the Aptel key is widened from 99910 to 9991 , and 0923 , 0931 , 0932 and 0934 are added.
NumberOperator beforeOperator after
09981234567شاتل موبایلشاتل موبایل
09983112345شاتل موبایلnull (allocated, holder not confirmed)
09231234567nullرایتل
09321234567nullتالیا

If you compare the whole details() array, expect the extra key allocated.

  1. Arabic number words. convert($n, 'ar') without options gives the same text as before for every number below 10^9. It now also accepts numbers up to 10^27, where it used to throw number_too_large . New options and ordinals are on the Arabic number words page.
  2. Platforms. PHP 8.2 to 8.5, Laravel 11, 12 and 13, and Carbon 3 are supported. See API stability .

From the early pre-release builds (before 0.1.0): namespaced helpers

1. Global helper functions are no longer defined by default

Breaking change. Earlier pre-release builds defined jdate(), to_persian(), is_national_code() and the other helpers as global functions as soon as Composer loaded the package. That could clash with your code or another package, so it was removed. The helpers now live in the RtlyKit namespace and nothing is defined globally.

The 27 helpers: jdate, hdate, hebrew_date, to_persian_digits, to_english_digits, to_persian, to_english, is_national_code, is_sheba, is_bank_card, is_mobile, is_postal_code, is_vehicle_plate, validate_national_code, validate_sheba, validate_bank_card, validate_mobile, validate_postal_code, validate_vehicle_plate, number_to_words, normalize_text, contains_rtl, text_direction, is_iran_holiday, prayer_times, format_number and ordinal.

Option A: import what you use (recommended).

PHP
use function RtlyKit\jdate;
use function RtlyKit\is_national_code;

echo jdate('2026-03-21')->format('Y/m/d');   // 1405/01/01

Or call the fully qualified name: \RtlyKit\jdate('2026-03-21').

Option B: bring back the old short global names. Call Globals::register() once, for example in your bootstrap file:

PHP
\RtlyKit\Globals::register();

echo jdate('2026-03-21')->format('Y/m/d');   // 1405/01/01, works as before

register() defines only the names that are still free. It never replaces an existing function and never throws. It returns the names it skipped because something else already uses them. You can call it more than once:

PHP
$skipped = \RtlyKit\Globals::register();
if ($skipped !== []) {
    error_log('RTLY-Kit globals skipped: '.implode(', ', $skipped));   // use \RtlyKit\name() for those
}

The Laravel service provider does not register globals either. In Blade, write {{ \RtlyKit\jdate($post->created_at)->format('j F Y') }}, or call Globals::register() from your AppServiceProvider::register(). More on Helpers and globals.

2. Other changes to be aware of

ChangeWhat to do
Validators take any value. All six implement RtlyKit\Contracts\Validator (validate(mixed): Result, isValid(mixed): bool). Non-string input (null, arrays, objects) returns an invalid Result with error invalid_type. Input over 4096 bytes returns input_too_long. They never throw for bad input.Remove defensive try blocks and type checks around validators. See Validators overview.
Exceptions carry codes. getErrorCode() (an ErrorCode enum) and getContext() exist on every library exception.Nothing to change. Match on the code and not on the message text. See Error handling.
Slugify::make() throws RtlyKitException (input_too_long or invalid_argument) for an invalid UTF-8 separator or one longer than 64 bytes.This matters only if you pass a separator that users control. Catch RtlyKitThrowable.
Calendars share a contract. Jalali, Hijri and Hebrew implement RtlyKit\Contracts\CalendarDate. Comparisons and diffIn*() accept a CalendarDate or any DateTimeInterface, make() accepts a CalendarDate, and equals(), isBefore(), isAfter() are new aliases.Existing calls keep working. See Convert and compare.
Reference data moved to resources/data/*.php.If you read the old table classes directly (they were never public API), switch to public classes such as Hijri, Sheba::getBankName() or NationalCode::getLocation().
Makkah Isha in Ramadan changed. PrayerTimes::METHOD_MAKKAH now returns Isha as Maghrib + 120 minutes during Ramadan (Umm al-Qura practice) and + 90 minutes otherwise. The previous build always used 90.Ramadan Isha moves 30 minutes later. This is a bug fix. Update any stored expected values. See the example below.

A check of the Makkah behaviour (2026-03-01 is in Ramadan, 2026-03-21 is not):

PHP
use RtlyKit\Prayer\PrayerTimes;

$mecca = PrayerTimes::forCity('mecca', PrayerTimes::METHOD_MAKKAH);
foreach (['2026-03-01', '2026-03-21'] as $d) {
    $t = $mecca->getTimes(new DateTimeImmutable($d));
    echo $d, ' maghrib ', $t['maghrib'], ' isha ', $t['isha'], "\n";
}
// 2026-03-01 maghrib 18:25 isha 20:25
// 2026-03-21 maghrib 18:32 isha 20:02

Changelog highlights for the 0.x series

These are the points of the changelog that users see. The repository's CHANGELOG.md has the full list.

Behaviour that changed

  • Out-of-range calendar input throws. Years, timestamps and huge add*() and sub*() amounts outside the supported ranges raise InvalidDateException and not a TypeError or a wrapped-around date. Ranges: Jalali -620 to 9377, Hijri 1 to 9665, Hebrew 3762 to 13759 ( Limits ).
  • make() string rules are fixed. A year below 1700 (Hebrew: 3000 or more) is read in the calendar of the class. Everything else is read as Gregorian. Blank strings throw, and null is now. A Gregorian ISO string with a year below 1700 is read as Jalali or Hijri, so pass a DateTimeImmutable for such dates ( FAQ ).
  • Holidays. Official dates for Jalali 1380 to 1405 (see above). For other years, Islamic holidays are reported only for AH 1300 to 1500, and a year outside the Jalali range throws InvalidDateException .
  • Number input. NumberToWords::convert() and Format::ordinal() accept whole floats ( 3.0 ) and throw InvalidNumberException for fractional floats, NaN and INF .
  • Input caps (security): strings over 4096 bytes are rejected by NumberToWords and Format , and Format::withSeparator() rejects numbers over 1000 characters. They throw InvalidNumberException ( input_too_long ).
  • Data tables keep confirmed entries. NationalCode::getLocation() covers 547 prefixes and returns null for the rest. The bank BIN (39), Sheba code (38) and operator tables keep only entries confirmed by two sources. A name that was returned before may now be null . BankCard rejects numbers made of one repeated digit.
  • Prayer-time core rewritten from standard astronomical equations. The difference from the earlier implementation was at most 2 minutes on evening events (mean 0.5 minute or less) across the comparison grid. Daylight saving is applied per event, and the $elevation constructor argument is new.

Added

  • A pure-PHP Hebrew calendar ( Hebrew ) with no ext-calendar needed. The Hijri calendar was rebuilt around the Umm al-Qura table for AH 1300 to 1500, with a HijriVariant::Tabular fallback.
  • Structured validation: Result ( isValid() , errors() , details() ) and validate_*() helpers with stable error codes.
  • NumberToWords::fromWords() , numbers up to 21 digits, negatives, and Arabic number words ( number_to_words($n, 'ar') ).
  • Normalizer::fixHalfSpace() and clean() , Detector::isRtlLocale() and isHebrew() , IranHolidays::getTitles() , isBusinessDay() and nextBusinessDay() , and PrayerTimes::nextPrayer() .
  • Laravel: the Jalali facade and the factory in the container, auto-discovery, six validation rules with fa, en and ar messages, and JalaliCast .
  • Carbon macros toJalali , jformat , createFromJalali , toHijri , toHebrew , createFromHijri , createFromHebrew .
  • The exception hierarchy under RtlyKitThrowable , the ErrorCode enum, Globals::register() , and the CalendarDate and Validator contracts.

Fixed

  • Makkah-method Isha in Ramadan (described above).
  • Format::ordinal() for 30 and 23. Format::withSeparator() accepts Persian and Arabic digits without rounding. Mobile accepts the 0098 , 98 and bare 9xxxxxxxxx forms. Sheba accepts the bare 24-digit form.
  • Prayer-time handling of time zones around daylight saving, and errors for an unknown method or Asr factor.
  • The duplicate hdate() helper declaration.

Note. While the library is below 1.0.0, pin a minor range (for example ~0.3.0), read this page before each minor upgrade, and run your own tests against any date and prayer-time outputs that you store.