Resources

Perimeter

perimeterQuery() sets the rows that exist for a client, on every endpoint and at every level of a request. Learn where it applies and how to write it.

The perimeter of a resource

perimeterQuery() sets the rows of a resource that exist for the client of the request. Rows outside it are never returned, never written and never deleted: for the client, they do not exist.

use Illuminate\Contracts\Database\Eloquent\Builder;

public function perimeterQuery(Builder $query): Builder
{
    return $query->where('team_id', $this->request()->user()->team_id);
}
  • Apply the constraints to $query itself and return it. The package ignores the returned builder, but an override that returns nothing throws a TypeError.
  • Read the request with $this->request(), and the user with $this->request()->user().
  • The default perimeter adds nothing: every row of the model, after its global scopes, exists.
  • Put it on your base resource, app/Rest/Resources/Resource.php, when its constraints apply to every resource.

Resources without a perimeter

php artisan rest:check lists the resources that the routes reach, through their relations too, and fails when one of them does not override perimeterQuery(): every row of such a resource is visible to the users who pass viewAny. Run it in CI. Mark a resource whose rows are meant for all these users, such as countries, with an attribute:

use Lomkit\Rest\Attributes\WithoutPerimeter;

#[WithoutPerimeter]
class CountryResource extends Resource
{
    public static string $model = Country::class;
}
  rows ... 1 resource with a perimeter, 1 marked #[WithoutPerimeter]
  App\Rest\Resources\CountryResource ... #[WithoutPerimeter]

See Artisan.

The perimeter decides which rows exist. A policy decides what the user may do with a row that exists. A row that the client may see but not change belongs in a policy, which answers 403. A row that must not exist for the client belongs in the perimeter, which answers as if the row did not exist. See Policies.

Where it applies

The perimeter of each resource applies wherever a request reaches its rows, at every level:

RequestThe perimeter applies to
searchthe rows of the page, each include level, each relation of a dotted include, each relation of a filter path, each relation group, the subquery of a relation sort, and each level of an aggregate
mutatethe keys of every entry, at the root and under relations, each through the perimeter of its own resource, and the records of the response block
destroy, restore, forceDestroythe keys of resources
actionsthe rows of the Search target and the keys of the Resources target
metricsthe query that the metric computes on
gatesthe update, delete, restore and forceDelete answers of a row
Rest::search()the same rows as the search endpoint, for the given user

Searches

A search reads the rows of the page through the perimeter. Each include, each filter on a relation and each aggregate applies the perimeter of the related resource, each in its own group:

{"search": {"filters": [{"field": "author.tags.name", "value": "featured"}]}}

This filter matches a post only through an author and a tag that the perimeters of their resources keep. An include renders null, or leaves a row out, when the perimeter of its resource leaves the related row out.

Writes

The keys of a mutation are checked during validation, with one query per resource class, through the perimeter of the resource of each row. A key outside the perimeter gives a 422, with the message of a key that does not exist:

{
  "message": "The selected mutate.0.key is invalid.",
  "errors": {
    "mutate.0.key": ["The selected mutate.0.key is invalid."]
  }
}
  • The rows that this query loads are the rows that the mutation writes: the perimeter runs once per request, before any write.
  • A created row is not checked against the perimeter: it is written, and its record in the response block is null when the perimeter leaves it out.
  • sync and toggle compare their keys with the attached rows that the perimeter of the related resource returns. An attached row outside it stays attached.

Keys out of reach

When a request names keys that the perimeter of their resource does not return, the package dispatches the Lomkit\Rest\Events\KeysOutOfReach event before the 422, once per resource and per request, so that a listener can record it. It covers the key of mutate entries and of relation entries, the resources of an action, destroy, restore and forceDestroy. The answer does not change. See Events and hooks.

Deletions

destroy, restore and forceDestroy read their keys through the perimeter, with the trashed rows on restore and forceDestroy. A key outside it gives The selected resources.0 is invalid. See Deleting.

One perimeter for every endpoint

A resource has one perimeter. To let one endpoint reach rows that the others leave out, branch on the endpoint of the request:

use Illuminate\Contracts\Database\Eloquent\Builder;
use Lomkit\Rest\Http\Routing\Endpoint;

public function perimeterQuery(Builder $query): Builder
{
    if ($this->request()->endpoint() === Endpoint::Mutate) {
        return $query;
    }

    return $query->where('published', true);
}

A row that the perimeter leaves out on one endpoint only gives a 422 on that endpoint only. A search still answers that endpoint's gates from the policy: put a condition that a gate must follow in the policy.

Conditions stay grouped

The conditions of the perimeter form one group, joined to the rest of the query with and. An orWhere() in it cannot widen the filters of the client or the keys of a write:

public function perimeterQuery(Builder $query): Builder
{
    return $query
        ->where('user_id', $this->request()->user()->id)
        ->orWhere('public', true);
}

This perimeter runs as (user_id = ? or public = ?) and ..., whatever the request adds after it. The conditions of scopes and instructions are grouped the same way.

Text searches on a search engine

A text search that runs on a Laravel Scout search engine takes its rows from the index: the rows of its page are loaded by key, without perimeterQuery(). Its resource declares the perimeter of the index with perimeter():

use Lomkit\Rest\Fields\Integer;
use Lomkit\Rest\Text\Engine\EngineQuery;
use Lomkit\Rest\Text\ScoutEngine;
use Lomkit\Rest\Text\TextSearch;

public function text(): ?TextSearch
{
    return ScoutEngine::make()->perimeter(
        fn (EngineQuery $query) => $query->where('team_id', '=', $this->request()->user()->team_id),
        [Integer::make('team_id')],
    );
}

Declare there every condition of perimeterQuery() that keeps rows from some users. Includes and aggregates of such a search still apply the perimeter of their own resource. See Search engines.

Global scopes and tenants

The global scopes of the model apply under the perimeter. A perimeter can take in rows that a global scope leaves out, such as with withTrashed(), and the write endpoints then reach them.

With several tenants, the perimeter is the place to scope every resource to the current tenant, on your base resource. A global scope of the model works too, and also applies to the rows of a search engine page.