Resources

Fields

Typed fields decide what clients select, sort, filter and write, how each value is validated, and how it is documented.

Declaring fields

fields() returns typed field objects. Each field names a column of the model and picks the type that matches it:

use Lomkit\Rest\Fields\Boolean;
use Lomkit\Rest\Fields\Date;
use Lomkit\Rest\Fields\DateTime;
use Lomkit\Rest\Fields\Enum;
use Lomkit\Rest\Fields\ID;
use Lomkit\Rest\Fields\Integer;
use Lomkit\Rest\Fields\Json;
use Lomkit\Rest\Fields\Number;
use Lomkit\Rest\Fields\Text;

public function fields(): array
{
    return [
        ID::make('id'),
        Text::make('title'),
        Text::make('summary')->nullable(),
        Integer::make('views'),
        Number::make('price'),
        Boolean::make('featured'),
        Enum::make('status'),
        Date::make('published_on')->nullable(),
        DateTime::make('published_at')->nullable(),
        Json::make('metadata')->nullable(),
    ];
}
ColumnFieldFilter operatorsBase rule
primary keyID::make('id')=, !=, in, not inthe key type: integer, string, uuid or ulid
string, textText::make('name')=, !=, like, not like, ilike, not ilike, in, not instring
integerInteger::make('views')=, !=, >, >=, <, <=, between, not between, in, not ininteger
decimal, floatNumber::make('price')same as Integernumeric
booleanBoolean::make('featured')=, !=boolean
dateDate::make('published_on')=, !=, >, >=, <, <=, between, not betweendate
datetime, timestampDateTime::make('published_at')same as Datedate
enum castEnum::make('status')=, !=, in, not inRule::enum() of the cast
json, jsonbJson::make('metadata')nonearray
  • Add ->nullable() to a nullable column. A nullable field accepts null in writes, and adds the is null and is not null operators.
  • A string in fields(), or a name declared twice, throws Lomkit\Rest\Exceptions\InvalidFieldException, which names the resource.
  • A name holds no dot.
  • Lomkit\Rest\Fields\DateTime has the same short name as PHP's \DateTime. In a file that uses both, alias one of them.

ID

ID reads its key type from the model: uuid or ulid when the model generates the column with HasUuids or HasUlids, int for an integer key type, string otherwise. Declare it when the model says otherwise:

ID::make('code')->keyType('string'),

keyType() takes int, string, uuid or ulid. The type of the ID field types every key that clients send: key in mutate, resources on the deletion endpoints and on actions. A key of another type gives a 422, such as The mutate.0.key field must be an integer.

Enum

Enum reads its enum class from the casts of the model. A field that is not bound to a model, such as the input of a scope or an action, names it:

Enum::make('status', PostStatus::class),

The details and the OpenAPI document list the backing values of the enum.

Offered cases

offered() withdraws some cases from writes, with a list of cases or a closure. A case left out stays readable, filterable and sortable, so the rows that hold it keep working:

Enum::make('status')->offered([ShowStatus::Planned, ShowStatus::OnSale]),
Enum::make('status')->offered(fn (ShowStatus $status): bool => $status !== ShowStatus::SoldOut),
POST /api/shows/mutate
{"mutate": [{"operation": "update", "key": 2, "attributes": {"status": "sold_out"}}]}
422 Unprocessable Content
{
  "message": "The selected mutate.0.attributes.status is invalid.",
  "errors": {
    "mutate.0.attributes.status": ["The selected mutate.0.attributes.status is invalid."]
  }
}
  • Without offered(), every case is offered.
  • Writes refuse a withdrawn case on its key: the attributes of mutate at every level, pivot fields, the fields of actions and instructions, the parameters of scopes, and Precognition requests.
  • An update that leaves the field out keeps the stored case, withdrawn or not.
  • Filters, sorts and rows keep every case: {"field": "status", "operator": "in", "value": ["sold_out"]} still finds the sold-out shows.
  • The details give offered next to values when cases are left out: "values": ["planned", "on_sale", "sold_out"], "offered": ["planned", "on_sale"]. The OpenAPI document lists the offered cases in the create, update, pivot and input schemas, and every case in the records and the filters.
  • A case of another enum, or a list or closure that offers no case, throws InvalidFieldException when the offered cases are first read: The enum field [status] offers no case. Offer at least one case, or declare the field readonly().

Json

Json works with or without a JSON cast on the model. When the model writes the attribute as it is, with no cast or a string, encrypted or AsStringable cast, and no set mutator, the field encodes the value before Eloquent writes it, and the response returns the decoded value. Any other cast, such as array, json, collection or AsArrayObject, and a set mutator receive the value as sent.

A Json value accepts at most 64 levels of nested arrays and objects, the top level included. A Json field cannot be filtered, and is sorted only when it declares sortable().

Reading fields

By default, a field is selectable, sortable and filterable with the operators of its type. These methods change what a search can do with it:

Declarationselects and rowssortsfilters
defaultyesyes, but not Jsonyes, but not Json
selectable(false)noonly with sortable()only with filterable()
writeOnly()never returnedonly with sortUsing()only with filterUsing()
virtual()only with resolveUsing()only with sortUsing()only with filterUsing()
sortable(false)yesnoyes
filterable(false)yesyesno
filterable(['=', 'like'])yesyesthese operators only

A field that a search cannot use gives a 422, as an undeclared field does: The selected search.filters.0.field is invalid.

Value types in rows

A row returns each value in the type that the OpenAPI document gives its field:

  • Number and Integer turn a numeric string into a number. A decimal:2 cast reads "25000.50" from the database, and the row returns 25000.5.
  • Json returns an empty array as {}. A list stays a list, and an object an object.
  • The other types, null, a string that is not numeric and a list on a Number field are returned as they are.
[
  {"id": 1, "price": 25000.5, "seats": 300, "settings": {"lights": "warm"}},
  {"id": 2, "price": null, "seats": 0, "settings": {}}
]

This covers the rows of a search, their includes and pivots, the records of the response block of a mutation, the values of resolveUsing() and the rows of Rest::search(). A custom map() receives the converted values. Read these values as numbers and objects in your clients.

Text::make('api_token')->writeOnly(),                // written, never returned
DateTime::make('created_at')->selectable(false),     // not returned, not sorted, not filtered
Text::make('slug')->filterable(false),               // refused in filters
Text::make('title')->filterable(['=', 'like']),      // = and like only
Text::make('summary')->nullable(),                   // the Text operators, plus is null and is not null

filterable([...]) narrows the operators of the field. An operator that its type does not have throws InvalidFieldException: Boolean::make('featured')->filterable(['>']).

Virtual fields

A field that is not a column of the table, such as an accessor, is declared virtual(). resolveUsing() gives its value, and requires() lists the columns that the callback reads:

use Illuminate\Database\Eloquent\Model;

Text::make('display_name')
    ->virtual()
    ->requires(['first_name', 'last_name'])
    ->resolveUsing(fn (Model $author): string => $author->first_name.' '.$author->last_name),
  • The callback receives the model of each returned row, and runs once per row.
  • The value is sent as the callback returns it, in the type of the field: a Json field does not decode a string, but returns an empty array as {}, and a Number or Integer field turns a numeric string into a number. See Value types in rows.
  • A field that is not virtual can declare resolveUsing() too: the callback then replaces the column value.
  • The appended attributes of a model ($appends) are never returned. Return an appended value through resolveUsing().

Custom sorts and filters

sortUsing() and filterUsing() replace the column of a field in sorts and filters. They make a virtual or write-only field sortable or filterable:

use Illuminate\Contracts\Database\Eloquent\Builder;

Text::make('full_name')
    ->virtual()
    ->sortUsing(fn (Builder $query, string $direction) => $query
        ->orderBy('last_name', $direction)
        ->orderBy('first_name', $direction))
    ->filterable(['=', 'like'])
    ->filterUsing(fn (Builder $query, string $operator, mixed $value) => $query
        ->where('first_name', $operator, $value)
        ->orWhere('last_name', $operator, $value)),
  • The sort callback receives the Eloquent query and asc or desc.
  • The filter callback receives the Eloquent query, the operator as a string and the value as sent. It can also be an object that implements Lomkit\Rest\Contracts\Filter. Its conditions stay in their own group, so an orWhere() does not leave the filter.
  • The callbacks also run at the end of a relation path, such as author.full_name.

See Filters and Sorts and selects.

Columns a search loads

A search loads only the columns that its rows need: the returned fields, the model key, the requires() of the returned fields, the keys of its includes, and the columns of alwaysSelect(). List there the columns that a policy, an accessor, an observer or a custom response reads without returning them:

public function alwaysSelect(): array
{
    return ['status', 'team_id'];
}

A gate policy, a resolveUsing() callback, a custom map(), the searched() event or searchMeta() that reads a column that the search did not load throws Lomkit\Rest\Exceptions\MissingColumnException, which names the resource, the column and the model. Set search.select_columns to false in config/rest.php to load every column. See Configuration.

Writing fields

A field is creatable and updatable by default. ID fields are not. These methods change what mutate accepts in attributes:

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;

ID::make('id'),                                    // refused on create and update
ID::make('uuid')->creatable(),                     // a key that clients send on create
DateTime::make('created_at')->readonly(),          // refused on create and update
Text::make('slug')->updatable(false),              // written on create only
Text::make('reference')->creatable(false),         // written on update only
Text::make('api_token')->writeOnly(),              // written, never returned
Text::make('full_name')
    ->virtual()
    ->fillUsing(function (Model $author, mixed $value): void {
        $author->forceFill([
            'first_name' => Str::before((string) $value, ' '),
            'last_name' => Str::after((string) $value, ' '),
        ]);
    }),
  • create accepts the creatable fields, and update, sync and toggle the updatable fields.
  • A virtual() field is refused unless it declares fillUsing(). The callback receives the model and the value, and runs after the other fields of the entry are filled.
  • An attribute that the operation cannot write gives a 422 under the attributes key of the entry: The created_at key of the mutate.0.attributes field is not writable on create.

Validating fields

Each field validates its value with the base rule of its type, nullable, then its own rules:

Text::make('title')
    ->rules('max:255')                 // on every write
    ->creationRules('required')        // on create
    ->updateRules('filled'),           // on update, sync and toggle

label() names the field in validation messages, and messages() replaces Laravel's text for its rules:

Text::make('email')
    ->label(__('email address'))
    ->creationRules('required', 'unique:users,email')
    ->messages(['unique' => __('This :attribute is already registered.')]),

See Validation for the resource rules, the rules that depend on the entry, and the order of the messages.

searchable() marks a Text field that the Contains and FullText text searches read:

Text::make('name')->searchable(),

Only Text fields, and field types that extend Text, can be searchable. A virtual or write-only field cannot. See Text search.

Documentation

Each field gives its OpenAPI type. examples() lists example values, which the request examples of the document use, and meta() adds keys that the details return with ?with=fields.meta and the document returns under x-lomkit-meta:

Text::make('priority')
    ->examples('low', 'normal', 'urgent')
    ->meta(['placeholder' => 'normal']),

See OpenAPI and Details.

Custom field types

A field type of your application extends a field class. Extend the closest type, so that the field keeps its operators, its base rule and its aggregates:

use Dedoc\Scramble\Support\Generator\Types\NumberType;
use Dedoc\Scramble\Support\Generator\Types\Type;
use Lomkit\Rest\Fields\Number;

class Money extends Number
{
    public function toSchema(): Type
    {
        return (new NumberType)->format('decimal');
    }
}
  • toSchema() returns the Scramble type of the field. A type that extends Field directly documents a string until it overrides it.
  • toWriteSchema() returns the type of the values that writes accept. It returns toSchema() unless the type overrides it.
  • render(mixed $value): mixed gives the response form of a value, read from the model or returned by resolveUsing(). A type that documents another type in toSchema() overrides render() to return that type. A type that already defines a method named render() renames it or gives it this signature.
  • avg and sum aggregates need a field that extends Number.
  • A search engine declaration refuses the app types that extend a field type. See Search engines.

Reserved names

A resource cannot declare a field named like a key that the package adds to its rows. The resource throws InvalidResourceException when its fields are first read:

  • the gates key, gates by default (gates.key in config/rest.php);
  • the more key, @more by default (search.more_key);
  • the morph type key, @type by default (morph.type_key), on a resource that a MorphTo relation loads. This one throws when a request resolves the target.

Input fields

Scopes, instructions and actions declare their inputs with the same field classes. Input fields take rules() and default(). creationRules() and updateRules() describe writes, and throw InvalidFieldException on an input field:

Integer::make('min_views')->default(100),
Boolean::make('notify')->rules('required'),

See Scopes and instructions and Actions.