PHP Packagev8.3.0MIT License

Readable

Human-friendly formatting for plain PHP. Readable turns raw values into text people can read at a glance: 1250000 becomes 1.3M, 1536 bytes become 1.5 KiB, 3661 seconds become 1 hour 1 minute 1 second. Each kind of value has its own small class of static methods, the output is the same on every machine, and Intl locales are there when you need them.

Numbers

Grouped, compact, percentage, ordinal and spelled-out numbers: 1,234,567, 1.2M, 75%, 21st and twenty-one.

Byte Sizes

1500 bytes become 1.5 KB, 1536 bytes become 1.5 KiB, and "10 MB" parses back to 10000000.

Money

1234.5 USD becomes USD 1,234.50, with the ISO 4217 minor units of every currency.

Durations

3661 seconds become 1 hour 1 minute 1 second, and two dates give a calendar-exact difference.

Strings & Arrays

Initials for avatars (Moamen Eltouny becomes ME), plus null, nested and list checks for arrays.

Optional Locales

Pass a locale such as de_DE to format with Intl, or leave it out for a deterministic English formatter.

Quick Tip

Every class lives in the Pharaonic\Readable namespace, so one grouped import covers them all: use Pharaonic\Readable\{Arr, Bytes, Duration, Money, Number, Str};

Installation

Install the package with Composer. There is nothing to register, publish or configure.

Requirements

  • PHP 8.3.x (each 8.x release line targets the matching PHP version)
  • ext-mbstring
  • ext-intl (optional): only needed when you pass a $locale

Composer Installation

Terminal
composer require pharaonic/php-readable

Composer autoloads the Pharaonic\Readable namespace. The package ships no global functions.

Installation Complete

You're all set! Try Number::compact(1250000), which returns 1.3M.

Basic Usage

Import the class for the kind of value you have and call a static method. Every formatter returns a string.

example.php
1use Pharaonic\Readable\{Arr, Bytes, Duration, Money, Number, Str};
2 
3Number::format(1234567.891, 2); // "1,234,567.89"
4Number::compact(1250000); // "1.3M"
5Bytes::format(1500); // "1.5 KB"
6Money::format(1234.5, 'USD'); // "USD 1,234.50"
7Duration::format(3661); // "1 hour 1 minute 1 second"
8Str::initials('Moamen Eltouny'); // "ME"
9Arr::isList([10, 20, 30]); // true

Named arguments

Optional arguments come after the value, so named arguments keep calls readable when you skip some of them:

example.php
1Number::format(70.50, 2, trimZeros: true); // "70.5"
2Bytes::format(1536, binary: true); // "1.5 KiB"
3Duration::format(90061, parts: 2); // "1 day 1 hour"
4Money::format(1234.5, 'USD', decimals: 0); // "USD 1,235"

Rules shared by every formatter

  • Rounding is half away from zero, like number_format(): Number::format(2.5) is "3" and Number::format(-2.5) is "-3".
  • No negative zero. A negative value that rounds to zero prints as zero: Number::format(-0.001, 2) is "0.00".
  • Invalid input throws InvalidArgumentException: INF, NAN, negative decimals, a malformed currency code, an unparsable size, or a date string PHP cannot read.
  • No state. There is no global configuration; everything a call needs is in its arguments.

Locales & Intl

Number::format(), percentage(), ordinal(), spell(), Bytes::format() and Money::format() accept an optional ?string $locale as their last argument.

Without a locale (default)

Readable uses its own English formatter: , for thousands, . for decimals, English suffixes and words. The output is identical on every machine, with or without the intl extension.

example.php
1Number::format(1234567.891, 2); // "1,234,567.89"
2Number::ordinal(22); // "22nd"
3Number::spell(7721); // "seven thousand seven hundred twenty-one"

With a locale

Readable formats through Intl's NumberFormatter, still rounding half away from zero:

example.php
1Number::format(1234567.891, 2, locale: 'de_DE'); // "1.234.567,89"
2Number::percentage(0.756, 1, 'de_DE'); // "75,6 %"
3Number::ordinal(21, 'fr'); // "21e"
4Number::spell(21, 'fr'); // "vingt-et-un"
5Money::format(1234.5, 'USD', locale: 'en_US'); // "$1,234.50"
6Bytes::format(1500, locale: 'de_DE'); // "1,5 KB"

For Arabic locales, Number::spell() attaches the conjunction "و" to the following word: Number::spell(21, 'ar') returns "واحد وعشرون".

Intl is required for locales

Passing a locale without ext-intl throws Pharaonic\Readable\Exceptions\MissingIntlExtension (a RuntimeException) instead of silently printing English. A locale that Intl itself rejects throws InvalidArgumentException.

Spaces in Intl output

Intl separates some parts with a no-break space (U+00A0) or a narrow no-break space (U+202F), for example in "1.234,50 €". Keep that in mind when you compare strings in tests.

Number::compact(), Money::compact(), Duration and Str have no locale argument: their output is always English.

Numbers

Pharaonic\Readable\Number formats integers and floats.

Grouped numbers

Number::format() groups thousands and fixes the number of decimals (0 by default):

example.php
1Number::format(1234567); // "1,234,567"
2Number::format(60708.547, 2); // "60,708.55"
3Number::format(70, 2); // "70.00"
4Number::format(-1234567); // "-1,234,567"
5Number::format(PHP_INT_MAX); // "9,223,372,036,854,775,807"

With trimZeros: true, trailing fraction zeros are dropped:

example.php
1Number::format(70.50, 2, trimZeros: true); // "70.5"
2Number::format(70.00, 2, trimZeros: true); // "70"
3Number::format(70.07, 2, trimZeros: true); // "70.07"
Custom separators

For other separators without a locale, use PHP's own number_format($number, 2, ',', '.').

Compact numbers

Number::compact() shortens a number with a K, M, B or T suffix, keeping at most $decimals fraction digits (1 by default) and dropping trailing zeros:

example.php
1Number::compact(999); // "999"
2Number::compact(77700); // "77.7K"
3Number::compact(77700, 0); // "78K"
4Number::compact(77370, 2); // "77.37K"
5Number::compact(1000000); // "1M"
6Number::compact(3400000000); // "3.4B"
7Number::compact(-1500); // "-1.5K"

When rounding reaches the next unit, the next unit is used: Number::compact(999950) is "1M", not "1,000K". Values past 999 trillion keep the T suffix: Number::compact(1.5e15) is "1,500T".

Percentages

Number::percentage() takes a ratio, where 1 is 100%:

example.php
1Number::percentage(0.75); // "75%"
2Number::percentage(1); // "100%"
3Number::percentage(0.1234, 1); // "12.3%"
4Number::percentage(-0.1); // "-10%"
Ratio, not percent

Number::percentage(75) is "7,500%". Divide by 100 first if your value is already a percentage.

Ordinals

example.php
1Number::ordinal(1); // "1st"
2Number::ordinal(22); // "22nd"
3Number::ordinal(113); // "113th"
4Number::ordinal(1001); // "1,001st"
5Number::ordinal(-11); // "-11th"

Numbers in words

Number::spell() follows ICU's English rules: no "and", hyphenated tens, and fraction digits read one by one. It covers every int and floats up to the decillions.

example.php
1Number::spell(21); // "twenty-one"
2Number::spell(101); // "one hundred one"
3Number::spell(7721); // "seven thousand seven hundred twenty-one"
4Number::spell(-5); // "minus five"
5Number::spell(3.14); // "three point one four"
6Number::spell(1000001); // "one million one"

Floats are read from their shortest exact representation, so Number::spell(0.1) is "zero point one".

Byte Sizes

Pharaonic\Readable\Bytes formats and parses byte counts.

Units

Two conventions, never mixed:

ConventionBaseUnitsHow
SI decimal1000B, KB, MB, GB, TB, PB, EB, ZB, YBdefault
IEC binary1024B, KiB, MiB, GiB, TiB, PiB, EiB, ZiB, YiBbinary: true

Formatting

Bytes::format() keeps at most $decimals fraction digits (2 by default) and drops trailing zeros:

example.php
1Bytes::format(0); // "0 B"
2Bytes::format(1000); // "1 KB"
3Bytes::format(1024); // "1.02 KB"
4Bytes::format(1500); // "1.5 KB"
5Bytes::format(1536, binary: true); // "1.5 KiB"
6Bytes::format(1073741824, binary: true); // "1 GiB"
7Bytes::format(1234, 1); // "1.2 KB"
8Bytes::format(-2048, binary: true); // "-2 KiB"

Rounding can move a value to the next unit: Bytes::format(999999) is "1 MB", not "1000 KB".

Parsing

Bytes::parse() turns a readable size back into an int. Units are case-insensitive, KB is decimal and KiB is binary, the same as format():

example.php
1Bytes::parse('512'); // 512
2Bytes::parse('512 bytes'); // 512
3Bytes::parse('10 MB'); // 10000000
4Bytes::parse('10mb'); // 10000000
5Bytes::parse('1.5 KiB'); // 1536
6Bytes::parse('2 GiB'); // 2147483648

Whole numbers are multiplied exactly; fractional bytes are rounded half away from zero.

Rejected input

Bytes::parse() throws InvalidArgumentException for anything it can't read unambiguously: a prefix without B ("10 K"), bits ("10 Mbit"), thousands separators ("1,000 KB"), exponents ("1e3 B"), or a size that does not fit in an int ("8 EiB").

Money

Pharaonic\Readable\Money formats amounts for display. It does no arithmetic and no exchange rates.

Formatting

Without a locale, the ISO 4217 code comes first and the currency's minor units decide the decimals:

example.php
1Money::format(100, 'USD'); // "USD 100.00"
2Money::format(1234.5, 'USD'); // "USD 1,234.50"
3Money::format(1234.5, 'JPY'); // "JPY 1,235"
4Money::format(1.5, 'KWD'); // "KWD 1.500"
5Money::format(-20, 'EGP'); // "-EGP 20.00"
6Money::format(1, 'usd'); // "USD 1.00"

Override the decimals with $decimals:

example.php
1Money::format(1234.5, 'USD', decimals: 0); // "USD 1,235"
2Money::format(1234.5, 'JPY', decimals: 2); // "JPY 1,234.50"

With a locale

Intl picks the symbol, its position and the separators:

example.php
1Money::format(1234.5, 'USD', locale: 'en_US'); // "$1,234.50"
2Money::format(-1234.5, 'USD', locale: 'en_US'); // "-$1,234.50"
3Money::format(1234.5, 'JPY', locale: 'en_US'); // "¥1,235"
4Money::format(1234.5, 'EUR', locale: 'de_DE'); // "1.234,50 €"

Compact amounts

example.php
1Money::compact(1500000, 'USD'); // "USD 1.5M"
2Money::compact(-2500, 'EUR'); // "-EUR 2.5K"
3Money::compact(999, 'EGP'); // "EGP 999"

Minor units

Money::fractionDigits() returns the ISO 4217 number of decimals for a currency:

example.php
1Money::fractionDigits('USD'); // 2
2Money::fractionDigits('JPY'); // 0
3Money::fractionDigits('KWD'); // 3
4Money::fractionDigits('CLF'); // 4

Currencies with 0 decimals: BIF, CLP, DJF, GNF, ISK, JPY, KMF, KRW, PYG, RWF, UGX, UYI, VND, VUV, XAF, XOF, XPF. With 3: BHD, IQD, JOD, KWD, LYD, OMR, TND. With 4: CLF, UYW. Every other code uses 2.

Currency codes

The currency must be three letters (any case). Anything else, such as "$" or "dollars", throws InvalidArgumentException. Well-formed codes that aren't in ISO 4217 are accepted and use 2 decimals.

Durations

Pharaonic\Readable\Duration writes lengths of time in English.

From seconds

example.php
1Duration::format(0); // "0 seconds"
2Duration::format(59); // "59 seconds"
3Duration::format(3661); // "1 hour 1 minute 1 second"
4Duration::format(187200); // "2 days 4 hours"
5Duration::format(604800); // "1 week"
6Duration::format(31536000); // "1 year"
7Duration::format(-3661); // "1 hour 1 minute 1 second"

format() uses fixed-length units only: a year is 365 days, then weeks, days, hours, minutes and seconds. It never uses months, because a month has no fixed length: 29 days is "4 weeks 1 day". The sign is ignored.

Between two moments

Duration::between() uses the real calendar through PHP's DateTimeInterface::diff(), so it does report months. It accepts DateTimeInterface objects, Unix timestamps and any string DateTimeImmutable understands, in any order:

example.php
1Duration::between('2024-01-01', '2025-03-15'); // "1 year 2 months 2 weeks"
2Duration::between('2024-02-01', '2024-03-01'); // "1 month"
3Duration::between(0, 3661); // "1 hour 1 minute 1 second"
4Duration::between($order->created_at, new DateTimeImmutable());

Moments in different time zones are compared as instants.

Daylight saving time

Within one time zone, whole days follow the wall clock: 12:00 to 12:00 across a DST change is "1 day", even though 23 hours elapsed. Hours count the time that actually elapsed: 00:00 → 06:00 on a spring-forward day is "5 hours".

Options

Both methods take the same three options after their main arguments:

OptionDefaultEffect
?int $partsnull (all)Keep only the largest non-zero units. Lower units are cut off, not rounded.
bool $shortfalseShort units: y, mo, w, d, h, m, s.
string $separator' 'Text between units.
example.php
1Duration::format(90061, parts: 2); // "1 day 1 hour"
2Duration::format(7199, parts: 2); // "1 hour 59 minutes"
3Duration::format(3661, short: true); // "1h 1m 1s"
4Duration::format(3661, separator: ', '); // "1 hour, 1 minute, 1 second"
5Duration::between('2024-01-01', '2025-03-15', parts: 2, short: true); // "1y 2mo"

parts must be 1 or greater; 0 throws InvalidArgumentException.

Strings

Pharaonic\Readable\Str has one helper, for the initials you show on avatars and badges.

Initials

Str::initials() takes the first letter or digit of each whitespace-separated word and uppercases it:

example.php
1Str::initials('Moamen Eltouny'); // "ME"
2Str::initials('moamen eltouny'); // "ME"
3Str::initials('Raggi'); // "R"
4Str::initials('élise ößler'); // "ÉÖ"
5Str::initials('3M Company'); // "3C"

With more words than $limit (2 by default), it keeps the first $limit - 1 words and the last one:

example.php
1Str::initials('John Ronald Reuel Tolkien'); // "JT"
2Str::initials('John Ronald Reuel Tolkien', 3); // "JRT"
3Str::initials('John Ronald Reuel Tolkien', 1); // "J"

Leading punctuation is skipped ('(Ada) "Lovelace"' → "AL"), words with no letters or digits are ignored ('Ada & Lovelace' → "AL"), a hyphenated word counts as one word, and combining marks stay with their letter. An empty or whitespace-only name returns "".

Invalid input

A $limit below 1 or a name that isn't valid UTF-8 throws InvalidArgumentException.

Arrays

Pharaonic\Readable\Arr answers three common questions about an array's shape.

Only nulls?

Arr::isNull() is true when every value is null. The check is strict: false, 0 and '' are not null.

example.php
1Arr::isNull([null, null]); // true
2Arr::isNull([null, 0]); // false
3Arr::isNull([false]); // false
4Arr::isNull([]); // true

An empty array has no non-null value, so it returns true.

Nested arrays?

Arr::isMultidimensional() is true when at least one value is an array, including an empty one:

example.php
1Arr::isMultidimensional([1, 2]); // false
2Arr::isMultidimensional([1, [2]]); // true
3Arr::isMultidimensional(['x' => ['y' => 1]]); // true
4Arr::isMultidimensional(['a' => []]); // true
5Arr::isMultidimensional([]); // false

A list?

Arr::isList() is true when the keys are 0, 1, 2, … in order:

example.php
1Arr::isList([10, 20, 30]); // true
2Arr::isList([]); // true
3Arr::isList([1 => 'a']); // false
4Arr::isList([1 => 'b', 0 => 'a']); // false
5Arr::isList(['a' => 1]); // false
Numeric string keys

PHP converts the key '0' to the integer 0 when it builds the array, so Arr::isList(['0' => 'a']) is true.

API Reference

Every method is public static. Methods marked throws raise InvalidArgumentException for invalid input; any method with a $locale raises Pharaonic\Readable\Exceptions\MissingIntlExtension when a locale is passed without ext-intl.

Pharaonic\Readable\Number

MethodDescriptionReturns
format(int|float $number, int $decimals = 0, bool $trimZeros = false, ?string $locale = null)Group thousands with a fixed number of decimals; trimZeros drops trailing fraction zeros.string
compact(int|float $number, int $decimals = 1)Shorten with K, M, B or T, at most $decimals fraction digits.string
percentage(int|float $ratio, int $decimals = 0, ?string $locale = null)Format a ratio (1 = 100%) as a percentage.string
ordinal(int $number, ?string $locale = null)1st, 2nd, 3rd, 4th, …string
spell(int|float $number, ?string $locale = null)The number in words.string

Pharaonic\Readable\Bytes

MethodDescriptionReturns
format(int|float $bytes, int $decimals = 2, bool $binary = false, ?string $locale = null)SI (KB) or, with $binary, IEC (KiB) size.string
parse(string $size)Readable size back to bytes, e.g. '10 MB' → 10000000. Throws.int

Pharaonic\Readable\Money

MethodDescriptionReturns
format(int|float $amount, string $currency, ?int $decimals = null, ?string $locale = null)Amount with its ISO 4217 code (or the locale's symbol); $decimals overrides the currency's minor units. Throws.string
compact(int|float $amount, string $currency, int $decimals = 1)Short amount, e.g. USD 1.5M. Throws.string
fractionDigits(string $currency)ISO 4217 minor units of a currency. Throws.int

Pharaonic\Readable\Duration

MethodDescriptionReturns
format(int $seconds, ?int $parts = null, bool $short = false, string $separator = ' ')Seconds in years (365 days), weeks, days, hours, minutes and seconds. Throws.string
between(DateTimeInterface|int|string $start, DateTimeInterface|int|string $end, ?int $parts = null, bool $short = false, string $separator = ' ')Calendar difference between two moments, in any order. Throws.string

Pharaonic\Readable\Str

MethodDescriptionReturns
initials(string $name, int $limit = 2)Uppercase initials of a name. Throws.string

Pharaonic\Readable\Arr

MethodDescriptionReturns
isNull(array $array)Every value is null (true for []).bool
isMultidimensional(array $array)At least one value is an array.bool
isList(array $array)Keys are 0, 1, 2, … in order.bool

Exceptions

ClassExtendsThrown when
Pharaonic\Readable\Exceptions\MissingIntlExtensionRuntimeExceptionA $locale is passed but ext-intl isn't loaded.
InvalidArgumentException (PHP)LogicExceptionINF/NAN, negative decimals, parts or limit below 1, a malformed currency code, an unparsable size or date, invalid UTF-8, or a locale Intl rejects.

Examples

1. A file list

Show each upload's size and age next to its name.

src/Uploads/FileRow.php
1use Pharaonic\Readable\{Bytes, Duration};
2 
3function fileRow(string $path): string
4{
5 $size = Bytes::format(filesize($path), 1, binary: true);
6 $age = Duration::between(filemtime($path), time(), parts: 1);
7 
8 return sprintf('%s · %s · %s ago', basename($path), $size, $age);
9}
10 
11// "report.pdf · 2.4 MiB · 3 days ago"

2. Dashboard stat cards

Big numbers stay short and percentages stay honest about rounding.

src/Dashboard/Stats.php
1use Pharaonic\Readable\Number;
2 
3$cards = [
4 'Visitors' => Number::compact(1284503), // "1.3M"
5 'Signups' => Number::format(48210), // "48,210"
6 'Conversion' => Number::percentage(48210 / 1284503, 2), // "3.75%"
7 'Rank' => Number::ordinal(3), // "3rd"
8];

3. Invoices in the customer's locale

The same amount, formatted for each customer, with a deterministic fallback when you don't know their locale.

src/Billing/InvoiceLine.php
1use Pharaonic\Readable\Money;
2 
3final class InvoiceLine
4{
5 public function __construct(
6 public float $amount,
7 public string $currency,
8 ) {}
9 
10 public function total(?string $locale = null): string
11 {
12 return Money::format($this->amount, $this->currency, locale: $locale);
13 }
14}
example.php
1$line = new InvoiceLine(1234.5, 'EUR');
2 
3$line->total(); // "EUR 1,234.50"
4$line->total('de_DE'); // "1.234,50 €"
5$line->total('en_US'); // "€1,234.50"
6 
7(new InvoiceLine(1234.5, 'JPY'))->total(); // "JPY 1,235"

4. Avatar placeholders

Users without a photo get their initials.

src/Users/Avatar.php
1use Pharaonic\Readable\Str;
2 
3function avatarLabel(string $displayName): string
4{
5 return Str::initials($displayName) ?: '?';
6}
7 
8avatarLabel('Moamen Eltouny'); // "ME"
9avatarLabel(' '); // "?"

5. Upload limits from configuration

Keep limits human-readable in configuration, parse them once, and show them back to the user.

src/Uploads/Limit.php
1use Pharaonic\Readable\Bytes;
2 
3$limit = Bytes::parse(getenv('UPLOAD_LIMIT') ?: '25 MB'); // 25000000
4 
5if ($file['size'] > $limit) {
6 throw new RuntimeException(sprintf(
7 'The file is %s; the limit is %s.',
8 Bytes::format($file['size']), // "31.46 MB"
9 Bytes::format($limit), // "25 MB"
10 ));
11}

Troubleshooting

MissingIntlExtension is thrown

You passed a $locale but the intl extension isn't loaded. Install and enable ext-intl, or drop the locale argument to use the built-in English formatter. Check with php -m | grep intl.

A string comparison with Intl output fails

Intl puts a no-break space (U+00A0) or a narrow no-break space (U+202F) in some formats, such as "1.234,50 €" or "75,6 %". Compare against the same characters, or replace them with a normal space before comparing. The exact space can change between ICU versions.

Bytes::format(1024) returns "1.02 KB"

KB is 1000 bytes. For 1024-based units pass binary: true: Bytes::format(1024, binary: true) returns "1 KiB".

Number::percentage(75) returns "7,500%"

percentage() expects a ratio where 1 is 100%. Pass 0.75, or divide your percent value by 100.

Duration::format() never shows months

Months have no fixed length, so a bare number of seconds can't be split into them correctly. Use Duration::between() with two dates when you need calendar months.

Bytes::parse() throws for "10K" or "10 Mbit"

A unit prefix needs a B (10 KB, 10 KiB), and bits aren't bytes. Parsing also rejects thousands separators, exponents and sizes that don't fit in an int.

Money::format() throws for "$" or "dollars"

The currency must be a three-letter ISO 4217 code such as USD. Map symbols or names to codes before formatting.

Duration::between() throws for a date string

The string must be something new DateTimeImmutable($string) can read. Pass a DateTimeInterface or a Unix timestamp when your input uses another format.

Contributors

Amazing people who made this package possible

This package wouldn't exist without the contributions of our amazing community. A huge thank you to everyone who has helped improve Readable!

Moamen Eltouny

@MoamenEltouny

8 contributions

Want to Contribute?

We welcome contributions from everyone! Whether it's bug fixes, new features, or documentation improvements - every contribution counts.