laravel-model-utils maintained by caue-santos
Laravel Model Utils
Eloquent helpers for the query-building problems that don't have a clean
built-in solution: joining a relation chain by hand with stable, collision-free
aliases; nested aggregate scopes (count/sum/avg/min/max) across a
dot-notation relation path several hops deep; registering runtime relation
aliases (including automatically-generated reverse relations for a forward
chain, so it can be traversed leaf-to-root); and reflecting a model's own
relation methods (type, keys, pivot, belongsToThrough chain) without
maintaining that metadata by hand.
Requirements
- PHP ^8.0
- Laravel (
illuminate/database,illuminate/support) ^6.0 through ^10.0, or 11.*
Installation
composer require caue-santos/laravel-model-utils
The service provider is auto-discovered.
Core concepts
| Concept | What it is |
|---|---|
ModelHelpers |
Static helpers for manual relation joins (loadRelationWithJoin, whereHasRelationWithJoin) and runtime relation registration (with, registerAliasedRelation, registerAliasedRelationWithFilters). |
Traits\RelationshipsTrait |
Add to a model to reflect its own relations (relationships()), check a dot-path exists (hasDefinedRelation()), and auto-register reverse relations for a forward chain (defineReverseRuntimeRelations()). |
Traits\HasWithAggregatesRelation |
Add to a model for scopeWithNestedCount/Sum/Avg/Max/Min/CountUnique — aggregate scopes across a dot-notation relation path. |
Traits\HasRecursiveRelation |
Adds scopeWhereRelationDepth(), a thin wrapper over ModelHelpers::whereHasRelationWithJoin(). |
Traits\HasRelationInspector |
Adds a static hasRelation() check — does this dot-path resolve to a real relation, including relations registered via resolveRelationUsing()? |
Joining a relation chain manually
ModelHelpers::loadRelationWithJoin() walks a dot-separated relation path
(HasOne, HasMany, BelongsTo, BelongsToMany) and joins every hop with a
generated, collision-free alias — reusing an existing join if the same query
already walked that path:
use CaueSantos\LaravelModelUtils\ModelHelpers;
$query = User::query();
ModelHelpers::loadRelationWithJoin($query, 'company.industry', function ($join, $relatedAlias) {
$join->where("{$relatedAlias}.active", true);
}, 'left');
ModelHelpers::whereHasRelationWithJoin() builds on the same join machinery
to add a WHERE EXISTS constraint instead of eager-loading columns:
ModelHelpers::whereHasRelationWithJoin($query, 'posts.tags', function ($subQuery, $lastAlias) {
$subQuery->where("{$lastAlias}.name", 'featured');
});
ModelHelpers::getJoinedAliases() / aliasFor() let other code (e.g. a
sort/filter layer built on top of this package) look up which alias a given
relation path already joined to, instead of re-deriving it.
Nested aggregate scopes
use CaueSantos\LaravelModelUtils\Traits\HasWithAggregatesRelation;
class Company extends Model
{
use HasWithAggregatesRelation;
}
Company::query()
->withNestedCount('users.posts as total_posts')
->withNestedSum('users.posts as total_likes', 'likes_count')
->withNestedAvg('users as avg_age', 'age', function ($subQuery, $finalAlias) {
$subQuery->where("{$finalAlias}.active", true);
})
->get();
A non-nested relation (no . in the path) delegates straight to Laravel's own
withAggregate(); a nested one builds the correlated subquery + joins itself,
walking HasOne/HasMany/BelongsTo/BelongsToMany hops with generated
aliases (t1, t2, ...) and applying the constraint callback against the
final hop's alias — passed explicitly as the callback's second argument so a
constraint targeting the base relation's own columns and one targeting the
deepest joined table are never ambiguous. withNestedCount/Sum/Avg/Max/Min
also accept an array to add several aggregates in one call:
Company::query()->withNestedCount([
'users.posts as total_posts',
'users.posts.comments as total_comments' => fn ($q) => $q->where('approved', true),
]);
Reflecting a model's relations
use CaueSantos\LaravelModelUtils\Traits\RelationshipsTrait;
class Post extends Model
{
use RelationshipsTrait;
}
$post = new Post;
$post->relationships();
// [
// 'author' => [
// 'name' => 'author', 'type' => 'BelongsTo', 'typeQualified' => BelongsTo::class,
// 'model' => User::class, 'table' => 'users', 'primary' => 'id',
// 'foreign_key' => 'user_id', 'local_key' => null, 'pivot' => [], 'through' => [],
// 'computed' => ['full_name' => 'getFullNameAttribute'],
// ],
// ...
// ]
Every public, zero-argument model method whose return type is (or includes,
for a union return type) an Illuminate\Database\Eloquent\Relations\Relation
subclass is reflected automatically — BelongsTo, HasOne, HasMany,
BelongsToMany, and staudenmeir/belongs-to-through's BelongsToThrough are
all recognised, each with the key pair/pivot/through-chain metadata relevant
to that relation type. Runtime relations registered via
Model::resolveRelationUsing() are included too.
hasDefinedRelation('company.industry') walks a dot-path through that same
reflection and returns the resolved chain (or false if any hop doesn't
exist) — defineReverseRuntimeRelations() uses it to register the inverse
of each hop on the corresponding child model (namespaced per parent, so
different chains sharing a child model don't collide), returning the reversed
dot-path a caller can hand to whereHas() to query the chain leaf-to-root.
Testing
composer install
vendor/bin/phpunit
Changelog
Please see CHANGELOG.md for more information on what has changed recently.
Security
If you discover any security related issues, please email cauesantosre4@gmail.com instead of using the issue tracker.