ᐊ back to home

Globalid Laravel

Tony Messias

Polymorphism is a very known concept in programming. To put it simply: it's the idea that many things can play the same role in the system. For instance, think about the Pull Request Reviewer feature on GitHub. You can assign a single team member, multiple, or an entire team as the reviewer. You may have code that does something like this:

1class User extends Model
2{
3}
4 
5class Team extends Model
6{
7}
8 
9class Reviewer extends Model
10{
11 use SoftDeletes;
12 
13 public function reviewer()
14 {
15 return $this->morphTo();
16 }
17 
18 public function setReviewerAttribute($reviewer)
19 {
20 $this->reviewer()->associate($reviewer);
21 }
22}
23 
24class PullRequest extends Model
25{
26 public function reviewers()
27 {
28 return $this->hasMany(Reviewer::class);
29 }
30 
31 public function syncReviewers(Collection $reviewers): void
32 {
33 DB::transaction(function () use ($reviewers) {
34 $this->reviewers()->delete();
35 $this->reviewers()->saveMany($reviewers);
36 });
37 }
38}

Then, in the PullRequestReviewersController@update action, you would have something like:

1class PullRequestReviewersController extends Controller
2{
3 public function store(PullRequest $pullRequest, Request $request)
4 {
5 $pullRequest->syncReviewers($this->reviewers($request));
6 }
7 
8 private function reviewers(Request $request)
9 {
10 // Returns new Reviewers based on the request...
11 }
12}

The PullRequestReviewersController::reviewers method will return a Collection of Reviewer instances. Building those new model instances can be tricky. Think about the form that is needed for this. The bare-minimum version of it would consist of a select field where you would list all Teams and Users as options. You could even group them in optgroup tags and label them accordingly:

1<x-select name="reviewers[]" id="reviewers" multiple class="block mt-1 w-full">
2 <option value="" disabled selected>Select the reviewers...</option>
3 <optgroup label="Teams">
4 @foreach ($teams as $team)
5 <option value="{{ $team->id }}">{{ $team->name }}</option>
6 @endforeach
7 </optgroup>
8 <optgroup label="Users">
9 @foreach ($users as $user)
10 <option value="{{ $user->id }}">{{ $user->name }}</option>
11 @endforeach
12 </optgroup>
13</x-select>

Not so fast... teams and users may have colliding IDs. Both their Database tables have different sequences. Even if it didn't, let's say you're using UUIDs or something like that, how would you go about deciding which model the UUID belongs to when you're processing the request? All solutions I can think of would require some kind of ad-hoc differentiation between teams and users. Maybe you do something like <table>:<id>, so users options would render to user:1, user:2, etc., while teams options would render to something like team:1, team:2, etc.

Then, you'd have to encode that mapping logic to do the actual fetching. It's messy. There's a better way.

Globalids

The Globalid Laravel package solves this problem. This package is a port of a Rails gem called globalid. Instead of coming up with an ad-hoc solution that would probably be different every time we have a problem like this, we can solve it this way:

1<x-select name="reviewers[]" id="reviewers" multiple class="block mt-1 w-full">
2 <option value="" disabled selected>Select the reviewers...</option>
3 <optgroup label="Teams">
4 @foreach ($teams as $team)
5 <option value="{{ $team->toGid()->toString() }}">{{ $team->name }}</option>
6 @endforeach
7 </optgroup>
8 <optgroup label="Users">
9 @foreach ($users as $user)
10 <option value="{{ $user->toGid()->toString() }}">{{ $user->name }}</option>
11 @endforeach
12 </optgroup>
13</x-select>

You would need to add the HasGlobalIdentification trait to both the Group and User models:

1use Tonysm\Globalid\Models\HasGlobalIdentification;
2 
3class User extends Model
4{
5 use HasGlobalIdentification;
6}
7 
8class Team extends Model
9{
10 use HasGlobalIdentification;
11}

The options' value fields would look something like this:

1gid://laravel/App%5CModels%5CTeam/1
2gid://laravel/App%5CModels%5CUser/1

The %5C here is the backslash (\) encoded to be URL-safe. This will work fine for a quick demo, but I'd highly recommend using something like Relation::enforceMorphMap() and avoiding using the model's FQCN for things like this. If you have a mapped morph, the package will use that. Something like this:

1Relation::enforceMorphMap([
2 'team' => Models\Team::class,
3 'user' => Models\User::class,
4]);

And the options' values will then render like this:

1gid://laravel/team/1
2gid://laravel/user/1

Then, our backend can be simplified quite a lot, we can leverage the Globalids using the Locator Facade, like so:

1use Tonysm\GlobalId\Facades\Locator;
2 
3class PullRequestReviewersController extends Controller
4{
5 public function store(PullRequest $pullRequest, Request $request)
6 {
7 $pullRequest->syncReviewers($this->reviewers($request));
8 }
9 
10 private function reviewers(Request $request)
11 {
12 return Locator::locateMany(Arr::wrap($request->input('reviewers')))
13 ->map(fn ($reviewer) => Reviewer::make([
14 'reviewer' => $reviewer,
15 ]);
16 }
17}

That's nice, isn't it? The Locator::locateMany accepts a list of Globalids and will return its equivalent models. It's smart enough to only do a single query per model type to avoid unnecessary hops to the database and all. In this case, we used the Locator::locateMany but if we were only dealing with a single option, we could stick to the Locator::locate method, which would take a global ID and return the model instance based on that.

In our case, since we're only dealing with form payloads we could use the globalid path like that, but that's not really safe to use it as a route param, for instance. Instead of encoding the globalid to string, we could call the ->toParam() method, which would return a base64 URL-safe version of the globalid that you can use as a route param. Something like this:

1Z2lkOi8vbGFyYXZlbC9ncm91cC8x

This could be useful if you were passing that as a route param like:

1POST /pull-requests/123/reviewers/Z2lkOi8vbGFyYXZlbC9ncm91cC8x

Preventing Tampering Ok, all that is fine and all, but there's an issue with this implementation. It's not very secure. Users could tamper with the HTML form and start poking around with your payload. That's not cool. Would be cool if there was a way to prevent users from tampering with the globalids like that, right? Well, there is! It's called SignedGlobalids. The API is slightly the same, but instead of calling ->toGid() on the model, you would call ->toSgid(). Like following:

1<x-select name="reviewers[]" id="reviewers" multiple class="block mt-1 w-full">
2 <option value="" disabled selected>Select the reviewers...</option>
3 <optgroup label="Teams">
4 @foreach ($teams as $team)
5 <option value="{{ $team->toSgid()->toString() }}">{{ $team->name }}</option>
6 @endforeach
7 </optgroup>
8 <optgroup label="Users">
9 @foreach ($users as $user)
10 <option value="{{ $user->toSgid()->toString() }}">{{ $user->name }}</option>
11 @endforeach
12 </optgroup>
13</x-select>

SignedGlobalids are cryptographically signed using a key derived from your app's APP_KEY, which means users cannot tamper with the form payload. Consuming this on your backend would then look like this:

1use Tonysm\GlobalId\Facades\Locator;
2 
3class PullRequestReviewersController extends Controller
4{
5 public function store(PullRequest $pullRequest, Request $request)
6 {
7 $pullRequest->syncReviewers($this->reviewers($request));
8 }
9 
10 private function reviewers(Request $request)
11 {
12 return Locator::locateManySigned(Arr::wrap($request->input('reviewers')))
13 ->map(fn ($reviewer) => Reviewer::make([
14 'reviewer' => $reviewer,
15 ]);
16 }
17}

The only difference is using locateManySigned instead of locateMany. Similarly, fetching a single resource would be locateSigned instead of the regular locate.

This can prevent users from tampering with the option values, but this does not prevent them from poking around in other places where you also use SignedGlobalids and find a signed option that they want to send in another form. If in your application you had another form that would also show them polymorphic options like that but to other models, for instance. They could then pick those options from the other form and use them on the one for reviewers. Since the options would be signed, your code would be tricked to accept it. That's not cool.

There are actually two ways you could go about it. When locating, you could tell the Locator that you're only interested in SignedGlobalids of the User model, for instance:

1private function reviewers(Request $request)
2{
3 return Locator::locateManySigned(Arr::wrap($request->input('reviewers')), [
4 'only' => User::class,
5 ])
6 ->map(fn ($reviewer) => Reviewer::make([
7 'reviewer' => $reviewer,
8 ]);
9}

That would only locate SignedGlobalids for the User model, ignoring every other non-User SignedGlobalId you may have. You can also define purposes for SignedGlobaids. This way, you can prevent users from reusing options just by copying and pasting the values from one form to a totally different one. For instance, our reviewers form could render the options passing the for option to the toSgid():

1<x-select name="reviewers[]" id="reviewers" multiple class="block mt-1 w-full">
2 <option value="" disabled selected>Select the reviewers...</option>
3 <optgroup label="Teams">
4 @foreach ($teams as $team)
5 <option value="{{ $team->toSgid(['for' => 'reviewers-form'])->toString() }}">{{ $team->name }}</option>
6 @endforeach
7 </optgroup>
8 <optgroup label="Users">
9 @foreach ($users as $user)
10 <option value="{{ $user->toSgid(['for' => 'reviewers-form'])->toString() }}">{{ $user->name }}</option>
11 @endforeach
12 </optgroup>
13</x-select>

Then, in our backend we would also have to specify the same purpose when locating the models, like this:

1private function reviewers(Request $request)
2{
3 return Locator::locateManySigned(Arr::wrap($request->input('reviewers')), [
4 'for' => 'reviewers-form',
5 ])
6 ->map(fn ($reviewer) => Reviewer::make([
7 'reviewer' => $reviewer,
8 ]);
9}

If the purpose encoded and signed on the SignedGlobalid doesn't match with the purpose you specify when locating, it wouldn't work.

Alternatively, you could also specify how long this SignedGlobalid will be valid, for instance, which could be useful if you're generating a public access link for some resource but you don't want it to be available forever, which helps preventing data from leaking out of your app in some cases. Read more about SignedGlobalids here.

Globalids are very useful in all sorts of situations where you want to use polymorphism. I'm using that in the Rich Text Laravel package, for instance, to store references to models when you use them as attachments. Instead of serializing the model, we can store the URI to that model and use the Locator to find it for us when it's time to render the document again.