mashirou1234/laravel-when-on-relation
Adds a whenOnRelation helper to Laravel Eloquent so you can run callbacks or apply constraints only when a relationship is loaded or present. It keeps relation-aware models, queries, and API resources concise and free of repetitive checks.
This package adds a single opt-in macro, whenOnRelation(), to Eloquent relationship instances. Laravel's built-in when() (from the Conditionable trait) fires whenever the condition is truthy, regardless of whether you are chaining off a model or a relation. whenOnRelation() narrows that: the callback only executes when the receiver is a relation instance, which is exactly the situation where relation-specific constraints (nested eager loads, wherePivot, withCount tweaks) actually make sense.
Minimum steps:
composer require the package and confirm it is auto-discovered (php artisan about or check vendor/composer/installed.json if the provider is not listed).src/ directory and read the macro registration first — that single file tells you the exact receiver type (Relation vs. BelongsTo/HasMany specifically) and the closure signature. Everything else in the assessment depends on that detail.php artisan tinker:>>> User::first()->posts()::class
=> "Illuminate\Database\Eloquent\Relations\HasMany"
>>> method_exists(User::first()->posts(), 'whenOnRelation')
=> true
Your first real use case is almost always a controller or scope that is reused by both a top-level query and a relation query. For example, a PostRepository that applies ->with('author') when called directly, but must not re-apply it when the caller already constrained the relation:
$user->posts()
->whenOnRelation(fn ($query) => $query->withCount('comments'))
->get();
Start here rather than retrofitting an existing global scope — you get the behavior change in one call site and can diff the SQL before widening adoption.
Guard shared scopes. The highest-value pattern is a local scope that must behave differently depending on how it was reached. Keep the top-level path untouched and wrap the relation-only branch:
public function scopeForListing(Builder $query): Builder
{
return $query->whenOnRelation(
fn ($q) => $q->withCount('comments')->orderByDesc('comments_count')
);
}
Nested resource controllers. In UserPostController@index the builder is $user->posts(), while PostController@index gets Post::query(). One form request / one filter class can serve both without an if ($builder instanceof Relation) branch.
Pivot-aware constraints. Pivot operations only exist on relation instances, so this is a natural fit — the callback is where wherePivot, withPivotValue, and using() belong. It removes the "am I definitely on a relation here?" doubt that makes pivot code fragile.
Compose with when() rather than replacing it. These are orthogonal: when() gates on your domain condition, whenOnRelation() gates on the call context. Chain them in that order for readable intent:
$query->when($request->boolean('with_stats'), fn ($q) => $q
->whenOnRelation(fn ($r) => $r->withCount('comments')));
Testing. Because the callback silently does nothing when the receiver is not a relation, assert both branches explicitly — one test hitting User::find(1)->posts(), one hitting Post::query(). Use DB::listen() or ->toSql() snapshots; a passing test that asserts only the result set will not catch a callback that never fired.
$query->getEagerLoads() or the emitted SQL in tests rather than on hydrated models.whenOnRelation() is not a substitute for when(). If your condition is a request flag or a config value, use when(). Reaching for the relation helper to express business rules will make call sites hard to reason about.Relation class or only on concrete relation types — a MorphTo or BelongsToMany may or may not be covered, and the failure mode is a BadMethodCallException rather than a quiet skip. Verify against every relation type you actually use before rolling it out.when() behavior is unchanged. The release notes are explicit about this, so no upgrade work is required for current code. Do not expect a codebase-wide behavior shift after composer update.whenOnRelation macro in a service provider, last registration wins. If behavior looks wrong after adding another dependency, dump Relation::getMacro('whenOnRelation') or check provider ordering in bootstrap/providers.php.when() idioms blindly.->dd() and ->dump() terminate or print at the point of call, so place them inside the callback to confirm it fired at all. If nothing prints, the receiver was not a relation — trace back one call in the chain to find where the relation was replaced by a plain Builder (e.g. after ->getQuery() or ->toBase()).How can I help you explore Laravel packages today?