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.
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.
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:
strtolower()iconv('UTF-8', 'ASCII//TRANSLIT', ...))state-of-the-art becomes four tokens)"was" is a stopword but "wa" — what the stemmer produces — is not)Factor this into a single Tokenizer/Normalizer class and call it from both the write path and the read path.
Indexing pattern (Laravel-flavoured).
search_terms text or JSON column (or a separate document_terms pivot table with term, document_id, frequency).ReindexDocument job — never in the request that saves the model if the corpus is large. Queue it.toSearchableArray() and emit the stemmed tokens as a separate field rather than replacing the human-readable text.LIKE search, store stems and query with WHERE search_terms LIKE '% ' || ? || ' %' — crude, but works for small datasets and avoids a search server.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.
PorterStemmer::stem('Running') may produce a different (and wrong) result than 'running'. Always lowercase first."is" to become anything else.[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."ran" → "ran", not "run"; "better" → "better", not "good". If users expect semantic matching for irregular English, you need a lemma dictionary on top."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.normalizer_version column so you can reindex when you change the pipeline (or swap stemmers).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.-s on certain tokens), you must copy the class into your own namespace — or write a decorator that short-circuits specific terms before delegating.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.How can I help you explore Laravel packages today?