mtownsend/read-time
Laravel package that estimates the read time of any text or article. It counts words, lets you customize words per minute, and returns a clean "X minute read" value—ideal for blog posts, docs, and content-heavy pages.
Install with Composer; the package is framework-agnostic but ships Laravel auto-discovery, so no manual service provider registration is needed on Laravel 5.5+:
composer require mtownsend/read-time
Read the release notes before you install. 2.0.0 is a major bump, and the published notes for it are empty in the data available — so do not assume the 1.x behaviour below is unchanged. Spend five minutes diffing vendor/mtownsend/read-time/src/ReadTime.php against the version you last used and confirm three things: the fully-qualified class name, the constructor signature (does it still take a second $wpm argument?), and the default output style. That file is a few dozen lines; the check is cheap and catches every plausible breaking change at once.
The minimal call, as of the 1.x surface, is:
use Mtownsend\ReadTime\ReadTime;
$text = strip_tags($post->body);
$readTime = (new ReadTime($text))->get();
// "3 minute read"
If a config file is published, do it once and commit it:
php artisan vendor:publish --tag=read-time
The config holds the words-per-minute default (typically 200) and the output style (abbreviated 3 min read vs. long form 3 minute read). Treat that file as your project's single source of truth for reading-speed assumptions. On a major upgrade, re-publish the tag into a scratch copy and diff it against your committed file — config key renames are the most common silent breakage in packages like this, and a stale key just falls back to the default with no error.
First use case: an article/blog show page (and index cards) where you print the read time next to the publish date. Start there, confirm the output reads correctly for your longest and shortest posts, then generalise.
Wrap it once, use it everywhere. Do not scatter new ReadTime(...) across controllers, Blade files, and API resources. Put a single adapter in app/Support so the WPM setting and HTML-stripping rule live in one place — and so a major-version breaking change is a one-file fix rather than a grep across the codebase:
namespace App\Support;
use Mtownsend\ReadTime\ReadTime;
class ReadingTime
{
public static function for(?string $html, ?int $wpm = null): string
{
$text = trim(preg_replace('/\s+/u', ' ', strip_tags($html ?? '')));
if ($text === '') {
return 'Less than a minute';
}
return (new ReadTime($text, $wpm ?? config('read-time.words_per_minute', 200)))->get();
}
}
Upgrade with the adapter as your blast radius. Before bumping to 2.0.0, run your adapter test suite (below) against the new version on a branch. If the tests pass, the upgrade is almost certainly clean regardless of what the release notes do or don't say. If the constructor signature changed, only ReadingTime::for() needs editing.
Persist it on the model. Reading time only changes when the body changes, so compute it in an observer/saving hook and store it in a reading_time column (or a Cache::remember("post:{$id}:read_time", ...) key). This keeps index pages — where you render 20 cards at once — free of 20 word counts per request. It also means a package upgrade can't retroactively blank your UI: existing rows keep their stored strings until the next save.
static::saving(function (Post $post) {
$post->reading_time = ReadingTime::for($post->body);
});
If 2.0.0 changes its output format, remember that persisted values are frozen in the old format — plan a one-off backfill (Post::chunkById(...)->each(fn ($p) => $p->update(['reading_time' => ...]))) rather than waiting for organic re-saves.
Blade component for presentation. The package returns a string, so presentation belongs in a component, not the model:
{{-- resources/views/components/read-time.blade.php --}}
@props(['content'])
<span {{ $attributes->merge(['class' => 'text-xs text-gray-500']) }}>
{{ \App\Support\ReadingTime::for($content) }}
</span>
API resources. Expose the same string in PostResource so web and mobile clients agree, rather than letting the frontend re-implement the calculation.
Tune WPM per content type. Technical tutorials and code-heavy posts read slower than marketing copy. Pass a per-post or per-category WPM (e.g. 200 for prose, 130–150 for tutorials) through your adapter rather than editing the package config globally.
Prefer pre-rendered HTML, then strip. If you only have Markdown source, strip code fences and link syntax before counting, otherwise URLs and $variable tokens inflate the total.
^2.0 only after that, and keep the lockfile committed.strip_tags() first. Passing raw HTML counts tag names and attribute values as words and can double the result. This is the single most common source of wrong numbers. , —, and collapsed newlines skew str_word_count. Normalise whitespace (preg_replace('/\s+/u', ' ', ...)) before instantiating.0 minute read or similar. Handle the empty/near-empty case in your adapter (as above) rather than showing something absurd.Cache::remember per post; recomputing per request across a paginated listing is wasted CPU.str_word_count(strip_tags($content)) alongside the package output to see exactly what it counted; a mismatch is almost always markup or entity noise, not the package. If the numbers agree but the string looks wrong after upgrading, the change is in the formatting layer — check the config's output-style key first.config/read-time.php, run php artisan config:cache (and clear it) or your change silently won't apply in production. This bites hardest during an upgrade, where you may be editing the file and changing its keys at the same time.ceil(words / wpm), so if 2.0.0 dropped or renamed something you rely on — or if you need locale-specific logic or a richer output format — wrapping (or forking — MIT) is cheaper than fighting the API. The adapter above exists precisely so that decision stays a one-file one.1 minute read), a 1,000-word fixture, and an HTML fixture to prove tags aren't counted. That test suite is your regression net both for a 2.0.0 upgrade and for dropping the dependency entirely — run it green on 1.x before bumping so you can tell a real break from a pre-existing one.How can I help you explore Laravel packages today?