dmn013/eloquence
Eloquence extends Laravel's Eloquent ORM with a suite of opt-in traits: fuzzy searchable models, sortable and sluggable records, soft deletes, cacheable queries, mappable attributes, and more. Drop-in behaviors that keep your models clean and your code expressive.
composer require dmn013/eloquence
Before writing any code, do two things:
jarektkaczyk/eloquence, last release 2019-09-24). Run composer require dmn013/eloquence -W and look at what it pulls in; if your app is on Laravel 10/11, expect the constraint to fail or the traits to break against modern HasAttributes/Builder internals. On anything newer than Laravel 6, plan to vendor the traits you use into app/Support/ rather than depend on the package.vendor/dmn013/eloquence/src/ first, not the README. The README is accurate for the 2019 release but namespaces moved between versions (Eloquence\SluggableTrait in older releases vs. grouped Eloquence\Behaviours\* traits later). The trait files are the source of truth for method names and property keys.Eloquence is not one feature — it is a bundle of independent traits. You opt into each one per model:
use Eloquence\SluggableTrait;
class Post extends Model
{
use SluggableTrait;
protected $sluggable = ['slug' => 'title'];
}
The first, highest-value use case is almost always Sluggable, because it kills a whole class of boilerplate (observer + uniqueness check + findBySlug):
$post = Post::create(['title' => 'Hello Eloquent World']);
$post->slug; // "hello-eloquent-world"
Post::findBySlug('hello-eloquent-world'); // model instance or null
Route binding then becomes trivial:
Route::get('/posts/{slug}', fn ($slug) => Post::findBySlugOrFail($slug));
Pick traits per model rather than applying the bundle everywhere — several of them override getAttribute/setAttribute/save, so the blast radius is real.
Sluggable — SEO routes and permalinks. Declare the source column(s) in $sluggable. For content that gets renamed, decide explicitly whether slugs are immutable (better for SEO, links never rot) or regenerated. Uniqueness is handled by appending -1, -2, but you should still have a unique index on the column as the real guard against concurrent inserts. Pair it with Recursive for category trees so /category/parent/child resolves off slugs at every level.
Recursive — category trees, comment threads, menu structures. Set the parent column and get parent(), children(), ancestors(), descendants(), isRoot(), plus reparenting helpers. This is adjacency-list, not nested set — cheap writes, recursive reads:
protected $parentColumn = 'parent_id';
$category->makeChildOf($parent); // reparent
$category->descendants; // whole subtree
Use it for depth ≤ 4 or so. Always eager load with constraints (with('children.children')) and index parent_id; a naive tree render will happily issue one query per node.
Metable — optional attributes without migrations. Attaches a polymorphic key/value store to the model. Excellent for per-post SEO overrides, per-tenant settings, feature flags on a record — anything where only 10% of rows use the field:
$post->meta->seo_title = 'Custom title';
$post->save(); // persists meta alongside the model
$post->meta->seo_title; // retrieve
$post->meta()->where('key', 'x'); // query the store
The trade-off is that meta is not a column: filtering, sorting, and indexing on meta values requires a join you write yourself, and each model needs the relation eager loaded (with('meta')) to avoid an extra query per row.
Mutable — attribute coercion in one place. Declares per-attribute types so you stop scattering getXAttribute/setXAttribute pairs across a model. Good for JSON-ish columns and typed scalars; it composes naturally with Metable for structured blobs.
Searchable — admin quick-search, not product search. A $searchable = ['title', 'body'] property plus a search($term) scope that ORs LIKE clauses. That is exactly right for a back-office "find this record" box and exactly wrong for anything user-facing with relevance ranking — reach for Scout/Meilisearch there. Keep it off unindexed large text columns.
QueryBuilder / Cacheable — plumbing. The query helpers are thin sugar over common where shapes; the cache trait wraps a read query in a cache key and invalidates on model save/delete. Use the cache trait only on read-heavy, write-rare endpoints, and never on queries you also mutate with Post::where(...)->update() — bulk operations bypass model events, so the cache and slugs both go stale.
Validating lets a model declare $rules and reject its own invalid saves. Convenient in seeders and admin forms; in request-facing code you still want FormRequests so errors surface at the HTTP layer.
getAttribute, setAttribute, or save. If you also have your own accessors, a HasAttributes override from another package, or two Eloquence traits touching the same hooks, method resolution order silently determines behavior. Add them one at a time and run the model test suite after each.fill() may not trigger your slug. Slugs are produced on save; if a column is not in $fillable the source value may be missing at build time. Verify with a create, an update, and an updateOrCreate.::update() and ::insert() bypass everything. Slugs, meta, validation, cache invalidation — all of it. Any code path that writes through the query builder needs a manual follow-up.$a->makeChildOf($b) where $b is already a descendant of $a produces an orphaned loop and descendants() will not terminate cleanly. Validate parentage in your form request.deleted_at. Concurrent inserts can both pass the "is it taken?" check — the unique index is what actually saves you.DB::listen(fn ($q) => logger($q->sql)) around a single page render; you will immediately see whether Metable/Recursive are N+1'ing. Eloquence's magic is invisible in the code, so the query log is your only reliable view.RefreshDatabase, point the cache driver at array in phpunit.xml, and assert on slugs explicitly — a green suite does not prove the slug hook fired if you never read the column.How can I help you explore Laravel packages today?