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

Porter Stemmer Laravel Package

camspiers/porter-stemmer

A lightweight PHP implementation of the Porter stemming algorithm from Cam Spiers. Reduce English words to their root forms for search indexing, text analysis, and matching. Simple API, Composer-ready, and suitable for Laravel or any PHP project.

Deep Wiki
Context7

Getting Started

Install

composer require camspiers/porter-stemmer

There is nothing to publish, no service provider, no config file. It is a single-class library: one namespace (Camspiers\PorterStemmer), one class (PorterStemmer), one static entry point (stem()). The whole implementation is a PHP port of Richard Heyes' regex-based Porter stemmer, so the source file is short enough to read end-to-end in a few minutes — do that first if you care about exact behaviour on edge cases.

Sanity check from the CLI

php -r "require 'vendor/autoload.php'; \
  echo Camspiers\PorterStemmer\PorterStemmer::stem('caresses'), PHP_EOL;"
# caress

If that fails with "class not found", the package uses an older autoload convention — run composer dump-autoload and inspect vendor/composer/autoload_psr4.php / autoload_namespaces.php for the mapping before assuming the namespace is wrong.

First real use case

The canonical use is search-index normalization: you have a products table (or a collection of documents) and want "running", "runs", and "ran-ish" text to match a query for "run". The minimum viable loop:

use Camspiers\PorterStemmer\PorterStemmer;

$tokens = preg_split('/[^a-z0-9]+/', strtolower($text), -1, PREG_SPLIT_NO_EMPTY);
$stems  = array_map([PorterStemmer::class, 'stem'], $tokens);

Then persist $stems (or a term => count map) in an indexed column and stem the user's query through the identical pipeline. The symmetry between index-time and query-time processing is the only thing that actually matters; the stemmer itself is a pure function and will not surprise you.

Implementation Patterns

Wrap it, don't sprinkle it. The static API is hostile to testing and to future swaps (Porter2, Snowball, a real lemmatizer). Put a thin adapter behind an interface:

interface Stemmer { public function stem(string $term): string; }

class PorterStemmerAdapter implements Stemmer
{
    public function stem(string $term): string
    {
        return PorterStemmer::stem($term);
    }
}

Bind it in AppServiceProvider and inject it. When you later migrate to wamania/php-stemmer or a Python microservice, only the binding changes.

Build one normalization pipeline and reuse it everywhere. Stemming is never the only step. The order that works:

  1. strtolower()
  2. strip HTML / punctuation / control chars
  3. transliterate accents (iconv('UTF-8', 'ASCII//TRANSLIT', ...))
  4. split on non-alphanumerics (hyphenated words: decide explicitly whether to split — state-of-the-art becomes four tokens)
  5. drop stopwords before stemming (stopword lists are unstemmed; "was" is a stopword but "wa" — what the stemmer produces — is not)
  6. stem
  7. optionally drop very short results

Factor this into a single Tokenizer/Normalizer class and call it from both the write path and the read path.

Indexing pattern (Laravel-flavoured).

  • Add a search_terms text or JSON column (or a separate document_terms pivot table with term, document_id, frequency).
  • Populate it in a model observer or an explicit ReindexDocument job — never in the request that saves the model if the corpus is large. Queue it.
  • For a Scout/Meilisearch/Algolia integration, override toSearchableArray() and emit the stemmed tokens as a separate field rather than replacing the human-readable text.
  • For SQL LIKE search, store stems and query with WHERE search_terms LIKE '% ' || ? || ' %' — crude, but works for small datasets and avoids a search server.
  • Keep the original surface forms. Users search for "universities" and expect to see "university" in results; the stem is for matching, not for display.

Cache aggressively. The stem of a given string never changes within a deployment. Vocabulary in real corpora is highly repetitive (Zipf), so an in-request array cache or a Redis stem:{word} key eliminates almost all regex work on repeat documents. A simple static $cache = [] inside your adapter gets you most of the way.

Batch workloads. For a full re-index, chunk the source rows (->chunkById(500)), stem once per unique term via the cache, and use upsert() rather than per-row saves. The stemmer is regex-heavy per call but fast; the database round-trips will dominate.

Testing. Porter's original paper and the Snowball site publish input/output vocabulary lists — use them as a data provider in PHPUnit. Also pin down your own contract tests for empty strings, digits, single characters, and mixed case so the behaviour is documented rather than accidental.

Gotchas and Tips

  • Case sensitivity. The implementation is written for lowercase ASCII input. PorterStemmer::stem('Running') may produce a different (and wrong) result than 'running'. Always lowercase first.
  • Short words pass through. Words of length ≤ 2 are returned unchanged — don't write assertions expecting "is" to become anything else.
  • No Unicode support. It operates on bytes with regexes tuned to [a-z]. Non-ASCII input (accented characters, CJK, emoji) will either be ignored or corrupt the result. Transliterate and strip before calling, or gate non-ASCII tokens out entirely.
  • Not a lemmatizer. "ran" → "ran", not "run"; "better" → "better", not "good". If users expect semantic matching for irregular English, you need a lemma dictionary on top.
  • Over-stemming is real. "universe" and "university" both collapse to "univers"; "organization" and "organ" collide. This is the intended recall/precision trade-off — surface it in your relevance tuning rather than treating it as a bug.
  • Don't double-stem. Running already-stemmed tokens back through the pipeline is not guaranteed idempotent across implementations, and it silently breaks your index/query symmetry if the two code paths differ. Stamp documents with a normalizer_version column so you can reindex when you change the pipeline (or swap stemmers).
  • Stemming an empty or whitespace string — guard before calling; don't rely on the library to handle it gracefully in a loop.
  • Static-only API means no mocking. You cannot stub PorterStemmer::stem() in a test. Any test that depends on stemmed output is an integration test unless you inject your own interface. This is the single strongest reason to wrap it on day one.
  • No extension points. There are no interfaces, events, or config knobs in the package. To customise behaviour (e.g. keep trailing -s on certain tokens), you must copy the class into your own namespace — or write a decorator that short-circuits specific terms before delegating.
  • Maintenance risk. Low star count and an old codebase; treat it as frozen. Pin the version in composer.json, and keep it behind your adapter so a swap to wamania/php-stemmer (multi-language, Porter2/Snowball) or a search server's built-in analyzer is a one-file change.
  • Don't stem inside SQL or in Blade. Do it once in PHP during indexing and once at query time; pushing it into a database function is a portability trap and you'll diverge from the PHP behaviour.
  • Debugging tip. When a search "should" match but doesn't, log the token pipeline output for both the document and the query at the same time. Nine times out of ten the mismatch is a stopword removed on one side, a casing difference, or a hyphen split — not the stemmer.
  • Attribution. MIT-licensed; if you vendor the file into your own tree rather than requiring it via Composer, keep the original copyright header.
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.
elmogy/larafast
xeviant/laravel-paystack
gorilladash/laravel-website-sdk
martialbe/flysystem-oss
chondal/3312client
jeroen-g/flickr
opscale-co/nova-genbi
itsjustvita/laravel-retention
tequilarapido/trackit
gregsphotography/site-builder-filament
cabaoctavio/laravel-query-log
gregsphotography/site-builder-components
io-digital/clickatell
snowsoft/laravel-admin
oxygen/auth
relicsoftware/barakah-payment-gateway
tobya/work-with-composer
equipc/laravel-email-validator
equipc/laravel-email-database-log
adedsec/nova-translated-labels