Skip to content

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 →