Web Development

Filter relationships by form value in Laravel Nova

Laravel Nova 4 added the option of dependent fields. We use them heavily to clean up admin interfaces, only showing fields relevant based on an existing context.

On a recent project, we needed to have a field whose selections depend on a previous selection. Here’s the case:

A Project model has a field to which team the project belongs. The project also has a primary contact. We only want to show primary contacts (User ID) that belong to the team that has been selected. In addition, we also have multiple fields that store user IDs.

The problem with a relatable query

Our first option is to start with a relatable query. This is a somewhat hidden functionality documented in the Field Authorization section of the Nova docs.

With a relatable query, you can restrict what a relationship field will return. In theory, exactly what we want to do.

In our case we have multiple models that relate to the User model, we might reach for something like this:

public static function relatableUsers(NovaRequest $request, $query, Field $field): Builder
{
    if (in_array($field->attribute, ['project_manager', 'assigned_to'])) {
        return $query->whereHas('teams', function ($q) {
            $q->whereIn('team_id', [ ... ]); // Replacing [...] with a list of team IDs.
        });
    }

    return $query;
}

Unfortunately, the relatable query has one major downside. It does not let us retrieve any values from previous selections in the form.

What we want to do is filter the list of users based on the selected team. It’s the same query, but we need access to the selected team ID. This we cannot get. A relatable query will only work when your filtering is fixed.

Using a select field instead and some request magic

Our solution was instead to replace the BelongsTo field with a Select field. This works in our case only because we always expect the list of users returned to be quite small. Take care of potential performance issues with bigger lists.

Our select query needs to take two cases into account:

  1. A create screen where the request has the selected Team ID.
  2. An update screen where the initial Team ID will be provided by the resource and subsequent updates by the request.
// The Nova resource has a BelongsTo field with the name "team" already.

Select::make('Primary Contact', 'primary_contact_id')
    ->options(function () use ($request) {
        return \App\Models\User::whereHas('teams', function ($q) use ($request) {

            // Get the "team" ID from the request, or default to the model attribute if not set.
            $q->where('team_id', $request->get('team', $this->team_id));

        })
            ->get(['first_name', 'last_name', 'id'])
            ->mapWithKeys(fn($user) => [$user->id => $user->name])
            ->toArray();
    })
    ->displayUsingLabels()
    ->nullable()
    ->hideFromIndex()
    ->dependsOn(
        ['team'],
        function (Select $field, NovaRequest $request, FormData $formData) {
            if ($formData->team === null) {
                $field->hide();
            }
        }
    ),

The code above is taken from our project example and shows both fetching the options, and the depends on logic.

Combining these two lets us create a dynamic and helpful interface for users, even with more complicated selections.

A wish for an upcoming Nova 4 version would definitely be to extend the dependent logic with this functionality natively for all relationship fields.