caue-santos / laravel-model-utils
Eloquent helpers for aliased/joined relation loading, nested aggregate scopes (count/sum/avg/min/max through dot-notation relation paths), runtime/reverse relation registration, and relation reflection.
Requires
- php: ^8.0
- illuminate/database: ^6.0|^7.0|^8.0|^9.0|^10.0|11.*
- illuminate/support: ^6.0|^7.0|^8.0|^9.0|^10.0|11.*
- staudenmeir/belongs-to-through: ^2.13
Requires (Dev)
- orchestra/testbench: ^4.0|^5.0|^6.0|^7.0|^8.0|^9.0
- phpunit/phpunit: ^8.4|^9.0|^10.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-07 18:58:24 UTC
README
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.