Skip to main content

Relation fields

Introduction

Qore has fields for most relationships that exist in Laravel.

If you're unsure on how to set up the Migration, Model relationship or Field take a look at Relationship cheat sheet.

caution

Before using relational fields, make sure you add the corresponding relationship to your model first.

If you do not do this, Qore will throw an error describing what you need to do.

Be also sure that you use the Laravel namespace for model relationships, and the Qore namespaces for Fields.

Fields

Most relationship fields expect another resource as the third argument. Currently the following relationship fields are supported:

BelongsTo::make('Medewerker', 'employee', Employee::class)
BelongsToMany::make('Medewerkers', 'employees', Employee::class)
HasOne::make('Hoofd medewerker', 'mainEmployee', Employee::class)
HasMany::make('Medewerkers', 'mainEmployees', Employee::class)
HasOneThrough::make('Owner','ticketMessageOwner', TicketMessageOwnerResource::class)
HasManyThrough::make('Owners','ticketMessageOwners', TicketMessageOwnerResource::class)
MorphOne::make('Address', 'address', Address::class)
MorphMany::make('Addressen', 'addresses', Address::class)
MorphTo::make('Something', 'morph')->resources(EmployeeResource::class, AddressResource::class)
MorphToMany::make('Tags', 'tags', TagResource::class)
BelongsToUser::make(__('User'), 'user') // Wrapper for BelongsTo
BelongsToManyUsers::make(__('Users'), 'users') // Wrapper for BelongsToMany
caution

The name of your field should be the same as the name of your defined relationship method on your Model. Alternatively, you can also chain ->onRelation($nameOfRelationMethod) on your field.

Scoping options

By default, every related resource model is appended as a selectable option. However, in a lot of cases you only want certain options to show. You can achieve this with scopeUsing:

BelongsTo::make('Primary contact', 'primaryContact', Contact::class)
->scopeUsing(function ($query, $model) {
if (!$model) {
return $query->where('is_private', false)
->whereNull('organization_id');
}

return $query->where('organization_id', $model->id);
})

Depending on other field values

In some cases you may want to change your scope based on the value of another field.

You can achieve this by using dependsOn:

->dependsOn('employee', function (Builder $query, $employeeId) {
if (!$employeeId) {
return $query;
}

return $query->whereIn('addresses.id', [1, 5]);
})

See more information here Field dependency.

Front-end behaviour

If you want the link(s) to open in a new tab:

BelongsTo::make(...)
->displayAsHref()
->openInNewTab()

If you want to show pills instead of <a> links everywhere, you can publish the qore config file and edit:

'preferences' => [
// ...
'display_as_href' => [
'default' => false
]
]

As tab / table

You can display BelongsToMany, HasMany and Morphmany as tables on the detail page. Both inside a tab, and as a card:

BelongsToMany::make('Medewerkers', 'employees', Employee::class)->asTab()
MorphMany::make('Addressen', 'addresses', Address::class)->asCard()

Attaching and detaching

When these relations are shown as a card or a tab, they will also contain actions to easily attach or detach certain records. You can however disable this:

BelongsToMany::make('Medewerkers', 'employees', Employee::class)->asTab()
->asCard()
->withoutDetaching()
->withoutAttaching()
info

In most cases when using HasMany in combination with attaching, you want to auto-fill the related field in the form that will be opened. You can achieve this with the attachOnField method:

HasMany::make('Tijdregistraties', 'timeRegistrations', TimeRegistration::class)
->attachOnField('employee')
->asTab()
->asCard()

By default, the name of the field will be checked against the relation defined on the model. You can change this by setting the relation name:

MorphTo::make('Something', 'morph')
->onRelation('addressable')
->resources(EmployeeResource::class, AddressResource::class)

If your database column name does not match your field name_id (e.g.: morph_id), you will need to set this:

MorphTo::make('Morph test', 'morph')
->onIdColumn('some_morph_id')
->resources(EmployeeResource::class, HobbyResource::class)

Attach buttons

You can also append a button on the resource detail page to quickly add a relation:

BelongsToMany::make('Medewerkers', 'employees', Employee::class)
->withAttachButtonInHeader() // Added in header
->withAttachButton() // Added as a block on the detail page

// You can also change the look (disable stacked and make it outlined)
->withAttachButton(false, true)

If you want to open the form in a new browser tab, you can add:

BelongsToMany::make('Medewerkers', 'employees', Employee::class)
->attachOnNewPage()

Sorting relations

By default, relation fields will sort on the foreign key that is defined in the relation. You can however supply your own sorting column on the following fields:

BelongsTo::make('Ticket', 'ticket', TicketResource::class)
->sortColumn('tickets.name')

HasOne::make('Ticket message', 'ticketMessage', TicketMessageResource::class)
->sortColumn('ticket_messages.description')

HasMany::make('Ticket messages', 'ticketMessages', TicketMessageResource::class)
->sortColumn('ticket_messages.description')

HasOneThrough::make('Ticket owner', 'ticketMessageOwner', TicketMessageOwnerResource::class)
->sortColumn('ticket_message_owners.name')

HasManyThrough::make('Ticket owners', 'ticketMessageOwners', TicketMessageOwnerResource::class)
->sortColumn('ticket_message_owners.name')

MorphOne::make(__('Mail footer'), 'mailFooter', MailFooterResource::class)
->sortColumn('mail_footers.name')
caution

The following fields do not support sorting: BelongsToMany, MorphMany, MorphToMany

Relational

Relational state

Most relation fields will append a "+" button to the input field. This will allow the user to create the resource without leaving the page.

Sometimes you may need to set the default state for the relational form. You can set the state:

BelongsToMany::make('Projecten (BTM)', 'projects', Project::class)
->withRelationalMeta(function(FormState $state) {
$state->hideFields('name');
$state->setState('description', 'abc');
})
->rules('nullable', 'exists:projects,id')

You can also get the related model if needed:

    ->withRelationalMeta(function(FormState $state) {
$relatedModel = $state->getRelatedModel();

// ..
})

You can also get the state and meta from the parent form using the following methods:

->withRelationalMeta(function(ManagesForm $form) {
$form->getParentState();
$form->getParentState('first_name');

$form->getParentMeta();
$form->getParentFieldMeta('field_name', 'key');
})

Relational table

When using the ->asCard or asTab method on a relation field, you might want to control which columns are shown by default in the table.

You can deselect fields by default:

BelongsToMany::make('Projecten (BTM)', 'projects', Project::class)
->withDeselectedTableColumns('description', 'template')

You can also apply the opposite, by choosing which fields should be shown by default:

BelongsToMany::make('Projecten (BTM)', 'projects', Project::class)
->withSelectedTableColumns('name', 'description')
caution

If the user has already defined his/her own table columns, this method will not work for that specific user until the user resets the table settings

The query used for the relation table may be overriden if neccessary:

BelongsToMany::make(..)
->withTableQueryUsing(function (Model $model) {
/** @var BelongsToMany|MorphToMany $relation */
$relation = $model->{$this->relationName}();

$relatedQuery = $relation->getQuery()
->select('*', $relation->getQualifiedRelatedPivotKeyName() . ' as id');

$relatedQuery->with($this->otherResource->indexQuery()->getEagerLoads());
return $relatedQuery;
});

If further customization is necessary on the columns, you can also modify them:

return HasMany::make(__('crm::crm.Contacts'), 'contacts', resource('contacts')->resourceClass())
// In this example, we only add the contact organization as a
// filter option in the dropdown instead of all organizations
->withTableColumnsUsing(function(ColumnCollection $collection, Model $relatedModel) {
$organizationColumn = $collection->findByName('organization');

// Filter options also accept a closure
$organizationColumn->filterOptions([
[
'label' => $relatedModel->name,
'value' => $relatedModel->id
]
]);
})

Relational table actions

When hovering over rows in the relation table, a small menu will be shown to detach your relation record. You can also add your own actions here, for example to update pivot table columns:

BelongsToMany::make('Labels (belongsToMany', 'labels', LabelResource::class)
->pivotFields(function (Model $model) {
return new FieldCollection(
Checkbox::make('Is active', 'is_active')
->tooltip($model->name)
);
})
->withTableActions(function (Model $parent) {
return new ActionCollection(
(new SetIsActiveAction())
);
})

You can also pass in an ActionCollection directly if you don't need the related (parent) model in a closure.

Now the SetIsActiveAction could look something like this:

class SetIsActiveAction extends Action
{
public function title(): string
{
return 'Set is active';
}

public function description(): string
{
return 'Set pivot column to active';
}

public function icon(): string
{
return 'rocket';
}

public function fields(): FieldCollection
{
return new FieldCollection(
Checkbox::make('Set active', 'active')
->default($this->relatedModel->labels()->find($this->model->id)->pivot->is_active)
);
}

public function run(Model $model, array $arguments): void
{
$this->relatedModel
->labels()
->updateExistingPivot(
$model, [
'is_active' => boolval($arguments['active'])
]
);
}
}

As you can see, you have access to the following properties in your action: $this->relatedModel, $this->relatedResource and $this->relatedResource.

Mutating filter options

You can change the filter options on a field based on the table records:

BelongsTo::make(
__('crm::crm.Organization'),
'organization',
resource('organizations')->resourceClass()
)
->postProcessFilterOptionsUsing(function (Collection $options, QoreResource $myResource, array $tableRecordIds) {
return $options
->whereIn('value', ContactModel::whereIn('id', $tableRecordIds)
->pluck('organization_id'))
->values();
})

The $tableRecordIds contains all the ids that are relevant for the table. Based on these ids, you might want to filter out any filter options.

Pivot

When working with a pivot table (https://laravel.com/docs/10.x/eloquent-relationships#retrieving-intermediate-table-columns), you might also have columns on the pivot table you need to see in the relation table.

Pivot fields

With the pivotFields call you can supply a FieldCollection or a Closure that expects a Model instance. These fields will be appended to the relation table. Because these fields exists on another table (the pivot table), Qore will automatically replace the index query for the relation table to support this. If you do not want this behaviour you can supply false to the second argument of the pivotFields method.

BelongsToMany::make('Labels', 'labels', LabelResource::class)
->pivotFields(function(Model $model) {
return new FieldCollection(
Checkbox::make('Is active', 'is_active')
->tooltip($model->name)
);
})

MorphToMany::make('Tags', 'tags', TagResource::class)
->pivotFields(new FieldCollection(
Checkbox::make('Is active', 'is_active')
), false)
caution

Because Qore adds filters & sorting to every field by default, you might have to double check if they are working for your pivot fields as well (because the values exists on separate tables).

Eventually you could disable them or add ->filterUsing and ->sortUsing yourself.

Pivot table columns

You can optionally set the values of each pivot column when your resource gets updated/created. For example, assuming we have the following tables:

Schema::create('tags', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->timestamps();
});

Schema::create('model_tags', function (Blueprint $table) {
$table->id();
$table->foreignIdFor(Tag::class)->constrained()->cascadeOnDelete();
$table->morphs('model');
$table->string('comment');
$table->timestamps();
});

Then you can set this comment column manually with the fillPivotUsing closure:

MorphToMany::make('Tags', 'tags', TagResource::class)
->pivotFields(new FieldCollection(
Text::make('Comment', 'comment')
))
->fillPivotUsing(function(array $syncIds, ?Model $model): array {
$sync = [];

foreach ($syncIds as $id) {
$sync[$id] = ['comment' => 'Model => ' . $model->id];
}

return $sync;
})
->asCard()
->asTab(),

Events

When a model gets attached or detached from a resource (via relation fields like HasMany, BelongsToMany, HasOne etc.), the following events will be dispatched:

ResourceRelationAttached::class
ResourceRelationDetached::class

On both events these public properties can be found: model, detachedModel / attachedModel, resource and detachedResource / attachedResource.

caution

When the field BelongsTo gets updated, an event will not be fired. This is because the column for the relation exists on the table of the model and it is not really considered attaching/detaching.

In the future this might be changed based on the opinion of developers.

Relationship cheat sheet

To make defining migrations and model relationships a bit easier, you can find the definitions per field below. Each definition is based around a Ticket model where the relation field is defined in the TicketResource.

BelongsTo

A Ticket can belong to an Organization:

// Migration:
Schema::table('tickets', function(Blueprint $table) {
$table->foreignIdFor(Organization::class)->nullable()->constrained()->nullOnDelete();
});

// Ticket Model:
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class);
}

// TicketResource Field:
BelongsTo::make('Organization', 'organization', OrganizationResource::class)

BelongsToMany

A Ticket can belong to many Organization:

// Migration:
Schema::create('organization_ticket', function(Blueprint $table) {
$table->foreignIdFor(Ticket::class)->constrained()->cascadeOnDelete();
$table->foreignIdFor(Organization::class)->constrained()->cascadeOnDelete();
});

// Ticket Model:
public function organizations(): BelongsToMany
{
return $this->belongsToMany(Organization::class, 'organization_ticket');
}

// TicketResource Field:
BelongsToMany::make('Organizations', 'organizations', OrganizationResource::class)

HasOne

A Ticket can have one Tag:

// Migration:
Schema::create('tags', function (Blueprint $table) {
$table->id();
$table->foreignIdFor(Ticket::class)->nullable()->constrained()->nullOnDelete();
$table->timestamps();
});

// Ticket Model:
public function tag(): HasOne
{
return $this->hasOne(Tag::class, 'ticket_id');
}

// TicketResource Field:
HasOne::make('Tag', 'tag', TagResource::class)

HasMany

A Ticket can have many TicketMessage:

// Migration:
Schema::create('ticket_messages', function (Blueprint $table) {
$table->id();
$table->foreignIdFor(Ticket::class)->nullable()->constrained()->nullOnDelete();
$table->timestamps();
});

// Ticket Model:
public function ticketMessages(): HasMany
{
return $this->hasMany(TicketMessage::class);
}

// TicketResource Field:
HasMany::make('Ticket messages', 'ticketMessages', TicketMessageResource::class)

HasOneThrough

A Ticket can have one Contact through TicketMessage:

// Migration:
Schema::create('ticket_messages', function (Blueprint $table) {
$table->id();
$table->foreignIdFor(Ticket::class)->nullable()->constrained()->nullOnDelete();
$table->timestamps();
});
Schema::table('contacts', function(Blueprint $table) {
$table->foreignIdFor(TicketMessage::class)->nullable()->constrained()->nullOnDelete();
});

// Ticket Model:
public function contact(): HasOneThrough
{
return $this->hasOneThrough(Contact::class, TicketMessage::class);
}

// TicketResource Field:
// NOTE: This field not shown on forms
HasOneThrough::make('Contact', 'contact', ContactResource::class)

HasManyThrough

A Ticket can have many Contact through TicketMessage:

// Migration:
Schema::create('ticket_messages', function (Blueprint $table) {
$table->id();
$table->foreignIdFor(Ticket::class)->nullable()->constrained()->nullOnDelete();
$table->timestamps();
});
Schema::table('contacts', function(Blueprint $table) {
$table->foreignIdFor(TicketMessage::class)->nullable()->constrained()->nullOnDelete();
});

// Ticket Model:
public function contacts(): HasManyThrough
{
return $this->hasManyThrough(Contact::class, TicketMessage::class);
}

// TicketResource Field:
// NOTE: This field not shown on forms,
// however attaching is possible via `->asCard()` and/or `->asTab()`
HasManyThrough::make('Contacts', 'contacts', ContactResource::class)
->asCard()
->asTab()

MorphOne

A Ticket can morph one Contact:

// Migration:
Schema::table('contacts', function(Blueprint $table) {
$table->morphs('entity');
});

// Ticket Model:
public function contact(): MorphOne
{
return $this->morphOne(Contact::class, 'entity');
}

// TicketResource Field:
MorphOne::make('Contact', 'contact', ContactResource::class)

MorphMany

A Ticket can morph many Contact:

// Migration:
Schema::table('contacts', function(Blueprint $table) {
$table->morphs('entity');
});

// Ticket Model:
public function contacts(): MorphMany
{
return $this->morphMany(Contact::class, 'entity');
}

// TicketResource Field:
MorphMany::make('Contacts', 'contacts', ContactResource::class)

MorphTo

A Ticket can morph to an entity:

// Migration:
Schema::table('tickets', function(Blueprint $table) {
$table->nullableMorphs('entity');
});

// Ticket Model:
public function entity(): MorphTo
{
return $this->morphTo('entity');
}

// TicketResource Field:
MorphTo::make('Entity', 'entity')
->resources(OrganizationResource::class, ContactResource::class)

MorphToMany

A Ticket can morph to many Tag:

// Migration:
Schema::create('tags', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->timestamps();
});
Schema::create('model_tags', function (Blueprint $table) {
$table->id();
$table->foreignIdFor(Tag::class)->constrained()->cascadeOnDelete();
$table->morphs('model');
$table->timestamps();
});

// Ticket Model:
public function tags(): MorphToMany
{
return $this->morphToMany(
Tag::class,
'model',
'model_tags'
);
}

// TicketResource Field:
MorphToMany::make('Tags', 'tags', TagResource::class)

BelongsToUser

A Ticket can belong to User:

caution

Make sure you link it to your local User model, because this will add an Avatar in the front-end.

// Migration:
use Illuminate\Database\Query\Expression;
Schema::table('tickets', function(Blueprint $table) {
$centralDb = tenant()->getConnection()->getDatabaseName();
$table->foreignIdFor(User::class)
->nullable()
->references('id')
->on(new Expression('`' . $centralDb . '`.users'))
->nullOnDelete();
});

// Ticket Model:
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}

// TicketResource Field:
BelongsToUser::make('User', 'user')

BelongsToManyUsers

A Ticket can belong to many User:

caution

Make sure you link it to your local User model, because this will add an Avatar in the front-end.

// Migration:
use Illuminate\Database\Query\Expression;
Schema::create('ticket_user', function (Blueprint $table) {
$table->id();
$table->foreignIdFor(Ticket::class)->nullable()->constrained()->nullOnDelete();
$centralDb = tenant()->getConnection()->getDatabaseName();
$table->foreignIdFor(User::class)
->nullable()
->references('id')
->on(new Expression('`' . $centralDb . '`.users'))
->nullOnDelete();
$table->timestamps();
});

// Ticket Model:
public function users(): BelongsToMany
{
return $this->belongsToMany(
User::class,
get_tenant_database_name() . '.ticket_user'
);
}

// TicketResource Field:
BelongsToManyUsers::make('Users', 'users')