Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Read Time Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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.

Implementation Patterns

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.

Gotchas and Tips

  • Upgrade blind at your peril. 2.0.0 shipped with no usable release notes in what's available here. Semver major means "assume breaking" until you've proven otherwise: diff the source, diff the published config, and run your adapter tests. Pin to ^2.0 only after that, and keep the lockfile committed.
  • Always 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.
  • Entities and whitespace. &nbsp;, &mdash;, and collapsed newlines skew str_word_count. Normalise whitespace (preg_replace('/\s+/u', ' ', ...)) before instantiating.
  • Code blocks and URLs. Fenced code and long URLs are counted as single "words" but real readers skim or skip them entirely; decide deliberately whether to strip them, and document the choice.
  • Short content. Very short posts round to 0 minute read or similar. Handle the empty/near-empty case in your adapter (as above) rather than showing something absurd.
  • CJK and non-space-delimited languages. Word counting is whitespace-based, so Chinese/Japanese/Thai text will be badly underestimated. If you have localised content, count characters instead and branch in your adapter.
  • Rounding expectations. The number is a ceiling-style estimate; two posts of 210 and 390 words may both render "2 minute read". Don't use it for sorting or analytics — use the raw word count for that.
  • Do not call it in a loop. On an index page, either read the persisted column or Cache::remember per post; recomputing per request across a paginated listing is wasted CPU.
  • Debugging. Print 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 caching. After editing 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.
  • Maturity. The package sat dormant after January 2021 and has now returned with a 2.0.0 major. Treat it as a small, lightly-maintained utility rather than a library with a stable extension surface: keep it pinned, don't build against its internals, and don't assume a deprecation cycle. The algorithm is essentially 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.
  • Testing. Write one test per branch of your adapter: empty string, a known 200-word fixture (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.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core