akyrey's avatar

afterQuery inside global scope

Hello, I have created a global scope to retrieve models only for the current tenant, something like this

final class TenantScope implements Scope
{
    public function apply(Builder $builder, Model $model): void
    {
        $tenant = Tenant::current();
        if ($tenant) {
            $builder->where($model->qualifyColumn('tenant_id'), $tenant->id);
        }
    }
}

Now I'd like to add an afterQuery to set the inverse of the relationship and avoid having to eager load the tenant for all models

->afterQuery(fn (Collection $results): Collection => $results
    ->map(fn (Model $model): Model => $model->getOriginal('tenant_id') === $tenant->id
        ? $model->setRelation('tenant', $tenant)
        : $model,
    ),
)

but I've noticed this doesn't get applied consistently, I think because inside the applyScope function there's a $builder = clone $this. Is there a workaround for this? May this be a bug? I've tried removing the clone but I'm getting some other errors.

Thank you in advance

0 likes
3 replies
LaryAI's avatar
Level 58

Certainly! Let’s break down your scenario and solution.

Problem Restated:
You want to apply a global scope for multi-tenancy, and you’d like to set the tenant relation on each retrieved model (using setRelation('tenant', $tenant)), to avoid eager loading, by using an afterQuery callback.
However, you’ve observed that afterQuery doesn’t work consistently inside a global scope, potentially due to internal clone calls in Eloquent’s applyScopes().


Solution

Why afterQuery is Unreliable in a Global Scope

You're right: Eloquent's core internally clones query builders and models while applying global scopes. That cloning can break the chain so that the afterQuery() configured in your custom builder or scope isn’t always carried over. This is expected due to how the framework manages query state.

Thus, the inconsistent execution of afterQuery() within a global scope is not a bug, but a consequence of the underlying query cloning behavior.


Alternative Approaches

1. Set the Relation in the retrieved Model Event

You can hook into the model's retrieved event. This ensures that when any model is hydrated from the database, you inject the correct relation.

In your model:

protected static function booted()
{
    static::retrieved(function ($model) {
        $tenant = Tenant::current();
        if ($tenant && $model->tenant_id === $tenant->id) {
            $model->setRelation('tenant', $tenant);
        }
    });
}

This will ensure that every time the model is instantiated, it will have the correct tenant relation set, without the need to eager load.


2. Use a Custom Collection

Alternatively, you can override the newCollection method in your model to set the relation on all models at once when a collection is hydrated:

public function newCollection(array $models = [])
{
    $tenant = Tenant::current();
    if ($tenant) {
        collect($models)->each(function ($model) use ($tenant) {
            if ($model->tenant_id === $tenant->id) {
                $model->setRelation('tenant', $tenant);
            }
        });
    }
    return new Collection($models);
}

3. Manually Map After Retrieval

If you only care about specific queries, you can wrap your query in a collection map instead of relying on a global scope:

$models = Model::all()->map(function ($model) use ($tenant) {
    if ($model->tenant_id === $tenant->id) {
        $model->setRelation('tenant', $tenant);
    }
    return $model;
});

Summary Table

Approach Global/Affects All? Applies to
Model retrieved event Yes All retrievals
Override newCollection Yes All collections
Manual map() after query No Single query

Final Recommendation

The retrieved event is generally the cleanest, Laravel-supported solution for your need:

protected static function booted()
{
    static::retrieved(function ($model) {
        $tenant = Tenant::current();
        if ($tenant && $model->tenant_id === $tenant->id) {
            $model->setRelation('tenant', $tenant);
        }
    });
}

This avoids both the performance impact of eager loading and the unreliable application of afterQuery in a global scope.


References:

Let me know if you need more details or a more advanced customization!

1 like
akyrey's avatar

Thank you Lary!! To let everyone else know, I used the retrieved event as suggested and it worked perfectly! Since I used a trait to register the global scope, a relationship and the cast (like the SoftDeletes trait), I registered the retrieved event listener in the trait boot method so that all models using that are working as expected!

kevinbui's avatar
but I've noticed this doesn't get applied consistently

What sort of inconsistency we are talking about here? Can you elaborate on that?

By the way, I think it would be a lot easier and cleaner to just eager load that tenant relationship on the results.

final class TenantScope implements Scope
{
    public function apply(Builder $builder, Model $model): void
    {
        $tenant = Tenant::current();
        if ($tenant) {
            $builder->where($model
                ->qualifyColumn('tenant_id'), $tenant->id)
                ->with('tenant');
        }
    }
}

I will just run one more DB query that is lightning fast, selecting a single tenant by id.

Please or to participate in this conversation.