Search by

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.

Maintainers

Package info

github.com/ca-santos/laravel-model-utils

pkg:composer/caue-santos/laravel-model-utils

Transparency log

Statistics

Installs: 26

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-09-07 18:53 UTC

This package is auto-updated.

Last update: 2026-09-07 18:58:24 UTC


README

GitHub Workflow Status Packagist Packagist Packagist

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.

Credits