Text Statistics & Readability — PHP source
Count words, sentences, paragraphs, characters, lines, and reading time, plus Flesch Reading Ease and Flesch-Kincaid grade-level readability scores.
This is the PHP implementation — the same logic the interactive tool runs, in a shareable, citable form.
<?php
// text-stats — PHP port
// Language: PHP 8.1+ (PCRE only, no Composer dependencies)
// CosmoDev polyglot showcase. Ported from src/lib/textStats.ts.
// Display source — part of CosmoDev's polyglot tool pages.
//
// Pure text-statistics & readability logic: counts characters, words, sentences,
// paragraphs, lines and syllables, and derives reading/speaking time plus the
// Flesch readability scores. Deterministic; analyze_text never throws.
namespace CosmoDev\TextStats;
/**
* The full analysis result. The three readability properties are nullable
* (mirroring the TypeScript `number | null`) when there are no words or no
* sentences to score.
*/
final class TextStats
{
public function __construct(
public readonly int $characters,
public readonly int $charactersNoSpaces,
public readonly int $words,
public readonly int $sentences,
public readonly int $paragraphs,
public readonly int $lines,
public readonly int $syllables,
public readonly int $readingTimeMs, // words / 200 wpm
public readonly int $speakingTimeMs, // words / 130 wpm
public readonly ?float $fleschReadingEase,
public readonly ?float $fleschKincaidGrade,
public readonly ?string $readabilityLabel,
) {}
}
/**
* Replicate JavaScript's Math.round, which rounds half-values toward +Inf.
* PHP's round() ties half away from zero, so the two disagree on negative
* half-values — and a negative Flesch-Kincaid grade landing exactly on n.5 is a
* real possibility. floor($x + 0.5) matches Math.round for the magnitudes here.
*/
function js_round(float $x): int
{
return (int) floor($x + 0.5);
}
/**
* Map a Flesch reading-ease score onto a qualitative label, following the
* original Flesch interpretation bands.
*/
function label_for_flesch(float $f): string
{
if ($f >= 80) return 'Very Easy';
if ($f >= 70) return 'Easy';
if ($f >= 60) return 'Standard';
if ($f >= 50) return 'Fairly Hard';
if ($f >= 30) return 'Hard';
return 'Very Hard';
}
/**
* Estimate the syllable count of a single word via a vowel-group heuristic.
* Cheaper than a dictionary lookup and accurate enough for readability scoring.
*/
function count_syllables(string $word): int
{
// Normalise to lowercase ASCII letters only, discarding digits, apostrophes
// and hyphens so "don't" / "well-being" are scored on their letter cores.
$w = preg_replace('/[^a-z]/', '', strtolower($word));
if ($w === '') {
return 0;
}
if (strlen($w) <= 3) { // $w is pure ASCII here, so byte length == char count
return 1;
}
// Drop a silent trailing 'e'/'es'/'ed'. 'l' is excluded from the consonant
// class so "...le" endings (apple, table) keep their final syllable.
$w = preg_replace('/(?:[^laeiouy]es|ed|[^laeiouy]e)$/', '', $w);
// A leading 'y' acts as a consonant ("yellow"); strip before grouping.
$w = preg_replace('/^y/', '', $w);
// Each maximal run of vowels is one syllable nucleus.
preg_match_all('/[aeiouy]+/', $w, $matches);
$count = !empty($matches[0]) ? count($matches[0]) : 1;
return max(1, $count);
}
/**
* Analyse a string and return its statistics. Accepts null (treated as the
* empty string) and never throws.
*/
function analyze_text(?string $input = null): TextStats
{
$text = $input ?? '';
// Code-point counts via PCRE. /u makes the engine UTF-8 aware; /\S/u counts
// non-whitespace code points directly (equivalent to stripping \s then
// measuring). Note: the TypeScript reference measures UTF-16 code units via
// string.length; for BMP text these agree and only diverge for
// supplementary-plane characters (emoji, rare CJK extensions).
$characters = preg_match_all('/./us', $text);
$charactersNoSpaces = preg_match_all('/\S/u', $text);
// Words: runs of ASCII letters/digits plus apostrophes (both ' and the
// typographic ') and hyphens, so contractions and hyphenated terms stay whole.
preg_match_all("/[A-Za-z0-9'\\x{2019}-]+/u", $text, $wordMatches);
$wordList = $wordMatches[0];
$words = count($wordList);
// A sentence ends at terminal punctuation followed by whitespace or EOF.
// No words -> zero sentences; otherwise clamp to >= 1 so a word block with
// no closing punctuation still counts as one sentence.
$sentences = 0;
if ($words > 0) {
$matched = preg_match_all('/[.!?]+(?:\\s|$)/u', $text);
$sentences = max(1, $matched);
}
// Paragraphs: split on two-or-more newlines, trim each block, drop empties.
// Whitespace-only input yields zero paragraphs.
$paragraphs = 0;
if (trim($text) !== '') {
foreach (preg_split('/\\n{2,}/', $text) as $block) {
if (trim($block) !== '') {
$paragraphs++;
}
}
}
// Lines: number of '\n'-separated rows. substr_count + 1 mirrors JS
// "abc\ndef".split('\n').length for non-empty input.
$lines = $text === '' ? 0 : substr_count($text, "\n") + 1;
$syllables = 0;
foreach ($wordList as $w) {
$syllables += count_syllables($w);
}
$readingTimeMs = js_round($words / 200 * 60000);
$speakingTimeMs = js_round($words / 130 * 60000);
// Readability requires at least one word and one sentence.
$fleschReadingEase = null;
$fleschKincaidGrade = null;
$readabilityLabel = null;
if ($words > 0 && $sentences > 0) {
$wordsPerSentence = $words / $sentences;
$syllablesPerWord = $syllables / $words;
$fleschReadingEase = js_round(
(206.835 - 1.015 * $wordsPerSentence - 84.6 * $syllablesPerWord) * 10
) / 10;
$fleschKincaidGrade = js_round(
(0.39 * $wordsPerSentence + 11.8 * $syllablesPerWord - 15.59) * 10
) / 10;
$readabilityLabel = label_for_flesch($fleschReadingEase);
}
return new TextStats(
characters: $characters,
charactersNoSpaces: $charactersNoSpaces,
words: $words,
sentences: $sentences,
paragraphs: $paragraphs,
lines: $lines,
syllables: $syllables,
readingTimeMs: $readingTimeMs,
speakingTimeMs: $speakingTimeMs,
fleschReadingEase: $fleschReadingEase,
fleschKincaidGrade: $fleschKincaidGrade,
readabilityLabel: $readabilityLabel,
);
}
Also available in 13 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →