Writing

Writing relations

Create, update, attach, detach, sync, toggle and delete related rows in a mutate entry, and write pivot fields with their rules.

The entries under relations write the related rows of a row. The operations that an entry accepts depend on the family of the relation.

app/Rest/Resources/TeamResource.php
use Lomkit\Rest\Fields\Integer;
use Lomkit\Rest\Fields\Text;
use Lomkit\Rest\Relations\BelongsTo;
use Lomkit\Rest\Relations\BelongsToMany;
use Lomkit\Rest\Relations\HasMany;

public function relations(): array
{
    return [
        BelongsTo::make('company', CompanyResource::class),
        HasMany::make('tickets', TicketResource::class)->deletable(),
        BelongsToMany::make('members', UserResource::class)->pivotFields([
            Text::make('role')->creationRules('required'),
            Integer::make('weight')->nullable(),
        ]),
    ];
}

Operations by relation family

RelationEntriescreateupdateattachdetachsynctoggledelete
BelongsTo, MorphToOne objectYesYesYesYes
HasOne, HasOneOfMany, MorphOne, MorphOneOfManyOne objectYesYesYesYesWith deletable()
HasMany, MorphManyA listYesYesYesYesWith deletable()
BelongsToMany, MorphToMany, MorphedByManyA listYesYesYesYesYesYes
HasOneThrough, HasManyThroughNone
  • An operation that the relation does not take gives The selected mutate.0.relations.products.0.operation is invalid.
  • A to-many relation takes a list of entries, and key there can be a list of keys. A to-one relation takes one object.
  • A custom relation lists its operations in mutationOperations(), as Lomkit\Rest\Fields\MutationOperation cases. A relation that returns [] takes no entry. delete comes from deletable() only: a Delete case in that list is ignored.
  • The details list the operations that each relation accepts, under operations, with ?with=relations.operations.
POST /api/teams/mutate
{
  "mutate": [
    {
      "operation": "update",
      "key": 1,
      "relations": {
        "company": {"operation": "attach", "key": 4},
        "tickets": [
          {"operation": "create", "attributes": {"title": "Printer jam"}},
          {"operation": "update", "key": 12, "attributes": {"status": "closed"}},
          {"operation": "detach", "key": 13}
        ],
        "members": [
          {"operation": "attach", "key": [7, 8], "pivot": {"role": "agent"}},
          {"operation": "sync", "key": [9], "without_detaching": true, "pivot": {"weight": 2}}
        ]
      }
    }
  ]
}
200 OK
{
  "operations": [
    {
      "operation": "update",
      "key": 1,
      "relations": {
        "company": {"operation": "attach", "key": 4},
        "tickets": [
          {"operation": "create", "key": 31},
          {"operation": "update", "key": 12},
          {"operation": "detach", "key": 13}
        ],
        "members": [
          {"operation": "attach", "key": [7, 8]},
          {"operation": "sync", "key": [9]}
        ]
      }
    }
  ]
}

Owner relations: BelongsTo and MorphTo

  • create writes a new owner, attach links an existing one, and both set the foreign key of the row. The entry is written before the row that holds it.
  • detach names the current owner of the row, and on a MorphTo its type. Another row, or a detach under a created row, gives The selected mutate.0.relations.author.key is invalid.
  • A MorphTo entry sends type, its morph type, required when the relation has several targets: {"operation": "attach", "type": "book", "key": 2}. Its response entry answers type too.
  • When a resource keeps the morph type column of a MorphTo writable, the column accepts the morph types of its targets only. Declare the morph columns readonly().

Child relations: HasOne, HasMany and the morph forms

  • create writes a child with the key of the parent, and on the morph relations its morph type (getMorphClass(), so the morph-map alias).
  • update and detach reach only a child of the parent: a row whose foreign key, and morph type, names the parent. Another row gives The selected mutate.0.relations.tickets.0.key is invalid., and so does any row under a created parent.
  • attach takes a row of another parent and moves it.
  • detach clears the foreign key of the child, and the morph type on the morph relations.
  • attach and create under a HasOne or MorphOne relation give a 422 while the parent already has a related row: The mutate.0.relations.loan field is prohibited: this relation already holds a record. Detach it first. Detach the current row in an entry written before. The of-many forms take another row.
POST /api/books/mutate
{
  "mutate": [
    {"operation": "update", "key": 2, "relations": {"loan": {"operation": "detach", "key": 5}}},
    {"operation": "update", "key": 2, "relations": {"loan": {"operation": "attach", "key": 7}}}
  ]
}

These checks run during validation, before any policy, hook or write, in the order of the entries: an entry sees what the entries before it attach, detach, create and delete.

Deleting child rows

A child relation that declares deletable() accepts delete entries: the rows are deleted in the transaction of the mutation, next to the other writes.

HasMany::make('tickets', TicketResource::class)->deletable(),
POST /api/teams/mutate
{
  "mutate": [
    {
      "operation": "update",
      "key": 101,
      "relations": {
        "tickets": [
          {"operation": "update", "key": 101, "attributes": {"status": "closed"}},
          {"operation": "delete", "key": [103]},
          {"operation": "delete", "key": 102}
        ]
      }
    }
  ]
}
200 OK
{
  "operations": [
    {
      "operation": "update",
      "key": 101,
      "relations": {
        "tickets": [
          {"operation": "update", "key": 101},
          {"operation": "delete", "key": [103]},
          {"operation": "delete", "key": 102}
        ]
      }
    }
  ]
}
  • deletable() exists on HasOne, HasOneOfMany, HasMany, MorphOne, MorphOneOfMany and MorphMany. Without it, at the root of mutate, and on BelongsTo, MorphTo and many-to-many relations, delete gives The selected mutate.0.relations.tickets.0.operation is invalid.
  • key is a key, or a list of keys under a to-many relation. A delete entry takes no attributes and no relations: The mutate.0.relations.tickets.0.attributes field is prohibited when mutate.0.relations.tickets.0.operation is delete.
  • Each key must name a child of the parent that the perimeterQuery() of the related resource returns, when the entry is written. Another key gives The selected mutate.0.relations.tickets.0.key is invalid.
  • A deleted row that another entry of the request also names gives The mutate.0.relations.tickets.1.key field has a duplicate value.
  • Before any write, each row is checked with the delete ability of the related resource, as on destroy, and with the link check of detach: the detach ability of the relation, or else update on the parent. A refusal answers 403, and nothing is written.
  • The row goes through the performDelete() of the related resource: a SoftDeletes model is soft deleted, never force deleted, and the model events run.
  • mutatingAll(), mutatedAll() and ResourceMutated see the deletions. The destroying(), destroyed(), destroyingAll() and destroyedAll() hooks of the destroy endpoint do not run: put a rule that holds on both endpoints in the policy or in a model observer.
  • Each deleted key counts as one entry toward rest.limits.sizes.relation_entries: The mutate.0.relations.tickets field goes over the limit of 100 entries, each deleted key counting as one entry.

Many-to-many relations

  • create writes a related row and its pivot row. attach adds pivot rows for existing rows. detach removes them.
  • sync keeps exactly the listed keys among the attached rows, and with "without_detaching": true only adds the missing ones. toggle attaches each listed key that is not attached, and detaches each listed key that is.
  • sync and toggle compare their keys with the attached rows that the perimeterQuery() of the related resource returns. Other attached rows stay.
  • sync and toggle answer the keys that they sent. The rows that they detach or attach besides these keys are not listed.
  • update, sync and toggle accept attributes for the related row, with its updatable fields. attach and detach take no attributes: The mutate.0.relations.tags.0.attributes field is prohibited when mutate.0.relations.tags.0.operation is attach.
  • without_detaching on another operation gives The mutate.0.without_detaching field is prohibited unless mutate.0.operation is in sync.

Pivot fields

A many-to-many relation declares its pivot columns with typed fields:

use Lomkit\Rest\Fields\DateTime;
use Lomkit\Rest\Fields\Integer;
use Lomkit\Rest\Fields\Text;

BelongsToMany::make('tags', TagResource::class)->pivotFields([
    Integer::make('weight')->nullable()->rules('min:0'),
    Text::make('added_by')->updatable(false),     // written on create and attach only
    Text::make('note')->writeOnly(),              // written, never returned
    DateTime::make('created_at')->readonly(),     // returned, never written
]),
{"operation": "attach", "key": 3, "pivot": {"weight": 5, "added_by": "import"}}
  • create and attach add a pivot row, so they accept the creatable pivot fields, with creationRules(). update, sync and toggle accept the updatable pivot fields, with updateRules(). detach refuses pivot.
  • A readonly() pivot field is refused on every operation. A writeOnly() pivot field is accepted.
  • Another key gives a 422 under pivot: The created_at key of the mutate.0.relations.tags.0.pivot field is not writable on create. On update, sync and toggle, it ends with on update.
  • Each value is validated under its key, mutate.0.relations.tags.0.pivot.weight: the base rule of its type, nullable, rules(), and the rules of the operation. The rules run when pivot is missing too, so a field with creationRules('required') is required on attach: The mutate.0.relations.tags.0.pivot.weight field is required.
  • A rule that names another pivot field, such as required_with:note, compares with that key in the pivot of the same entry.
  • update, sync and toggle validate with the updatable fields, also when they add a pivot row, because validation runs before the database is read. Use attach to add a row with the creation rules.
  • A pivot field declared virtual(), or with fillUsing() or resolveUsing(), throws InvalidFieldException, and so does a string or a name declared twice.
  • An Enum pivot field reads its enum class from the casts of the pivot class, the class given to using() or Laravel's Pivot.
  • In a search, the pivot of an included row holds the selectable pivot fields only, and filters on tags.pivot.weight accept the filterable ones, see Filters.

Attaching or detaching a row checks the link on the policy of the parent's resource. A relation can name its own abilities:

BelongsToMany::make('tags', TagResource::class)
    ->abilities(attach: 'attachTag', detach: 'detachTag'),
app/Policies/BookPolicy.php
public function attachTag(User $user, Book $book, Tag $tag): bool
{
    return $user->can('update', $book) && ! $tag->archived;
}
  • Without abilities(), a link checks update on the parent.
  • A declared ability without a policy method is denied.
  • A refused link answers 403 before any write. See Policies.