Skip to content

[12.x] Add NamedScope attribute #54450

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 18 commits into from
Mar 19, 2025
Merged

Conversation

shaedrich
Copy link
Contributor

@shaedrich shaedrich commented Feb 3, 2025

Instead of

class User extends Model
{
    public scopeActive(Builder $builder)
    {
        // …
    }
}

you can now write

use Illuminate\Database\Eloquent\Attributes\NamedScope;

class User extends Model
{
    #[NamedScope]
    public active(Builder $builder)
    {
        // …
    }
}

similar to #40022

class User extends Model
{
    public getActiveAttribute()
    {
        // …
    }
}

and

class User extends Model
{
    public active()
    {
        return Attribute::make(
            get: fn() => // …
        );
    }
}

Complements #50034

FAQs

What if I already have a method that has the same name as a scope?

Your application will not be broken because the method does not have the NamedScope attribute, which did not exist before this pull request.

Will the old, multi-method approach of defining local scopes go away?

No. This is just an alternative approach.

@taylorotwell
Copy link
Member

Drafting pending for @crynobone - please mark as ready for review once reviewed.

@taylorotwell taylorotwell marked this pull request as draft February 5, 2025 10:41
@crynobone
Copy link
Member

@shaedrich using public method here means the method can be called directly and it would be common the pass the instance of Illuminate\Database\Eloquent\Builder. IDE with autocomplete would assume the first parameter to be Builder while we usually can pass something else.

@shaedrich
Copy link
Contributor Author

I can make it private/protected—no problem 👍🏻

return null;
}

$method = new ReflectionMethod($this, $scope);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think we need to check if $method return false for incorrect usage such as abstract method or public function?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In lines 1638 – 1640, we test if the method exists—isn't that enough, meaning do we care about how the method is implemented?

@@ -27,4 +35,10 @@ public function scopeExists()
{
return true;
}

#[NamedScope]
public function existsAsWell()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to also test if child model can utilise attribute named scope defined in parent model.

method need to be updated to protected and contains parameter as example to real usage

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm afraid, I can't follow you? Can you elaborate on that?

Copy link
Member

@crynobone crynobone Feb 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CleanShot 2025-02-05 at 19 48 00

This would be the actual usage, correct?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct 👍🏻

Signed-off-by: Mior Muhammad Zaki <crynobone@gmail.com>
Copy link
Member

@crynobone crynobone left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given the following example:

<?php

namespace App\Models;

// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Contracts\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Attributes\NamedScoped;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;

class User extends Authenticatable
{
    /** @use HasFactory<\Database\Factories\UserFactory> */
    use HasFactory, Notifiable;

    /**
     * The attributes that are mass assignable.
     *
     * @var list<string>
     */
    protected $fillable = [
        'name',
        'email',
        'password',
    ];

    /**
     * The attributes that should be hidden for serialization.
     *
     * @var list<string>
     */
    protected $hidden = [
        'password',
        'remember_token',
    ];

    /**
     * Get the attributes that should be cast.
     *
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'email_verified_at' => 'datetime',
            'password' => 'hashed',
        ];
    }

    #[NamedScoped]
    public function verified(Builder $builder, bool $email = true)
    {
        return $builder->when(
            $email === true,
            fn ($query) => $query->whereNotNull('email_verified_at'),
            fn ($query) => $queryu->whereNull('email_verified_at'),
        );
    }

    public function scopeVerifiedUser(Builder $builder, bool $email = true)
    {
        return $builder->when(
            $email === true,
            fn ($query) => $query->whereNotNull('email_verified_at'),
            fn ($query) => $queryu->whereNull('email_verified_at'),
        );
    }
}

Accessing named scope via query builder:

CleanShot 2025-02-06 at 18 33 13

Accessing named scope statically:

CleanShot 2025-02-06 at 18 34 17

Converting public function verified to protected function verified

CleanShot 2025-02-06 at 18 35 13

Signed-off-by: Mior Muhammad Zaki <crynobone@gmail.com>
@crynobone
Copy link
Member

Added failing tests based on above finding

Signed-off-by: Mior Muhammad Zaki <crynobone@gmail.com>
Signed-off-by: Mior Muhammad Zaki <crynobone@gmail.com>
Signed-off-by: Mior Muhammad Zaki <crynobone@gmail.com>
@Bosphoramus
Copy link

Wouldnt #[LocalScope] be a better name for the attribute?

@antonkomarev
Copy link
Contributor

#[QueryBuilderScope]

@shaedrich
Copy link
Contributor Author

Wouldnt #[LocalScope] be a better name for the attribute?

#[QueryBuilderScope]

Yeah, I thought about this, too.

  • #[LocalScope]: While this may be the "correct" name to differentiate it from global scopes, this might sound like a variable scope and therefore might be confusing without further context.
  • #[QueryBuilderScope]: While this avoids the ambiguity described above, it, in turn, doesn't differentiate between local and global scopes

@taylorotwell @crynobone What do you think?

@shaedrich shaedrich marked this pull request as ready for review February 8, 2025 17:54
@crynobone crynobone changed the base branch from master to 12.x February 18, 2025 22:03
@CWAscend
Copy link

CWAscend commented Feb 22, 2025

I like this idea, and appreciate that this is trying to align with the changes to model accessors. I'm wondering if we can more closely align them though - this implementation relies on PHP attributes, whereas the Attribute acccessor doesn't.

Just thinking out loud here, but what about something like this, where Reflection is used to determine the method based on the QueryScope return type, rather than method attributes?

public function verified(bool $email = true): QueryScope
{
    return QueryScope::make(
        query: fn (Builder $query) => $query->when(
            $email,
            fn ($query) => $query->whereNotNull('email_verified_at'),
            fn ($query) => $query->whereNull('email_verified_at'),
        )
    );
}

Not overly fussed either way though, was just my initial thoughts!

@shaedrich
Copy link
Contributor Author

@CWAscend I actually considered that, however, I felt, this would be more in line with the #[ScopedBy] attribute for global scopes

@taylorotwell
Copy link
Member

taylorotwell commented Mar 19, 2025

@shaedrich this is working pretty well for me. If we took the return type approach, I wonder what the return type would actually be and what the user would return?

@taylorotwell taylorotwell merged commit 6713393 into laravel:12.x Mar 19, 2025
39 checks passed
@shaedrich
Copy link
Contributor Author

@taylorotwell Thanks for merging! You mean @CWAscend's approach? I assume, QueryScope would work quite similar to Eloquent's Attributes—did you see any problems or challenges that may arise from this? Are you asking because you consider providing yet an alternative approach or just entertaining the idea in general?

@jasonmccreary
Copy link
Contributor

Nice contribution @shaedrich. Going to add this refactor to Shift.

@shaedrich
Copy link
Contributor Author

Nice, thanks, @jasonmccreary. Lookin forward to it.

@decadence
Copy link
Contributor

Renamed to Scope

@shaedrich
Copy link
Contributor Author

@decadence Please do not just suggest to rename something that has already been merged with its name. Please instead read the discussion above surrounding the naming as this has already been subject of debate and only reopen the debate if you have reasonable and new arguments for it. Thank you

@decadence
Copy link
Contributor

decadence commented Apr 2, 2025

@shaedrich What are you talking about? I'm not suggesting, I warn future readers. PR name is "Add NamedScope attribute" while real attribute is called Scope now. And there is no mention about it in thread. Except @newtonjob examples.

@shaedrich
Copy link
Contributor Author

Ah, damn, sorry—Taylor snuck that in, which wasn't obvious from the commit messages 😅 My bad ^^

@shaedrich
Copy link
Contributor Author

fyi: Another issue just arose with static analysis (thanks to @DarkGhostHunter for reporting it 👍🏻)
https://mastodon.social/@darkghosthunter/114446232883864791

namespace App\Models;

use App\ValueObjects\Item;
use App\ValueObjects\Discount;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Attributes\Scope;

/**
 * @method \Illuminate\Database\Eloquent\Builder<static>|static admins()
 */
class Cart extends Model
{
    #[Scope]
    protected function admins (Builder query)
    {
        $query->where('is_admin', true);
    }
}

Cart::admins()->get();

// Error: Accessing a protected property "admins()" publicly.
// Error: Method "admins" requires the parameter "$query" to be a "...Builder" instance.
// Error: Method "admins" on PHPDoc doesn't match the signature on the class.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

9 participants