Associate dan Dissociate
Associate memasukkan record yang sudah ada ke dalam relasi one-to-many dengan menulis foreign key milik child; dissociate mengeluarkannya kembali dengan mengosongkan foreign key tersebut. Keduanya adalah padanan hasMany/hasOne untuk attach dan detach: tidak ada join row yang dibuat atau dihapus, sehingga child hanya berpindah kepemilikan. Masing-masing pasangan action disembunyikan pada bentuk relasi yang bukan miliknya, sehingga sebuah relasi hanya menawarkan satu cara untuk membawa record yang sudah ada ke dalam relasi, bukan dua cara sekaligus.
Manager yang melakukan associate dan dissociate
<?php
declare(strict_types=1);
namespace App\Panels\Admin\Resources\Projects\RelationManagers;
use App\Panels\Admin\Resources\Projects\ProjectResource;
use Illuminate\Database\Eloquent\Model;
use PandaPanel\Actions\Relations\DeleteRelatedAction;
use PandaPanel\Actions\Relations\DissociateAction;
use PandaPanel\Actions\Relations\EditRelatedAction;
use PandaPanel\Forms\Components\TextInput;
use PandaPanel\Forms\FormSchema;
use PandaPanel\Resources\RelationManager;
use PandaPanel\Tables\Columns\TextColumn;
use PandaPanel\Tables\TableSchema;
final class TasksRelationManager extends RelationManager
{
protected static string $relationship = 'tasks';
protected static ?string $recordTitleAttribute = 'name';
public static function table(TableSchema $table, Model $owner): TableSchema
{
return $table
->columns([
TextColumn::make('name')->searchable()->sortable(),
])
->recordActions([
EditRelatedAction::make(ProjectResource::class, self::class, $owner),
DissociateAction::make(self::class, $owner),
DeleteRelatedAction::make(self::class, $owner),
]);
}
public static function form(FormSchema $schema, Model $owner): FormSchema
{
return $schema->schema([
TextInput::make('name')->required()->maxLength(255),
]);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
Child harus memiliki foreign key yang nullable:
Schema::table('tasks', function (Blueprint $table): void {
$table->foreignId('project_id')->nullable()->constrained()->nullOnDelete();
});2
3
Tombol associate muncul sendiri di atas table — AssociateAction adalah header action yang di-resolve oleh RelationTable, bukan action yang perlu dideklarasikan manager. Sebaliknya, DissociateAction adalah record action yang Anda tambahkan sendiri.
AssociateAction
use PandaPanel\Actions\Relations\AssociateAction;
AssociateAction::make(string $resource, string $manager, Model $owner): Action;2
3
| Properti | Nilai |
|---|---|
| Name | associate |
| Label | Associate {manager title} |
| Icon | link |
| Variant | ActionVariant::Outline |
| Type | form — membuka dialog yang diambil dari relation form endpoint |
| Terlihat ketika | RelationManager::isOneToMany($owner) |
| Diotorisasi oleh | RelationManager::canAssociate($owner) → associateAny pada owner |
Dialog hanya berisi satu field: searchable select yang menentukan record mana yang akan diadopsi ke relasi.
GET /{panel}/relations/form?resource=projects&record=7&relation=tasks&operation=associate
POST /{panel}/relations/form?resource=projects&record=7&relation=tasks&operation=associate
{ "related": "12" }2
3
Write dilakukan melalui $relation->save($related) pada HasOneOrMany. Operasi inilah yang mengisi foreign key — dan morph type jika relation bersifat polymorphic:
$related = $relation->getRelated()->newQuery()->find($key);
if ($related !== null) {
$relation->save($related);
}2
3
4
5
Record apa saja yang dapat di-associate
Option berasal dari RelationManager::attachableOptions(), method yang sama dengan yang digunakan attach dialog: seluruh record related model yang belum berada di relasi ini, dibatasi maksimal 50 dan dapat dicari melalui options endpoint.
GET /{panel}/options?resource=projects&record=7&relation=tasks&operation=associate&field=related&search=orph"Belum berada di relasi ini" tidak sama dengan "belum dimiliki siapa pun". Child yang saat ini dimiliki owner lain tetap ditawarkan, dan melakukan associate akan memindahkannya ke owner saat ini — sesuai makna mengadopsi existing record. Override attachableOptions() jika hanya orphan record yang boleh ditawarkan:
/**
* @return list<array{value: string, label: string}>
*/
public static function attachableOptions(Model $owner, ?string $search = null, int $limit = 50): array
{
$options = [];
$query = Task::query()->whereNull('project_id')->orderBy('name')->limit($limit);
if ($search !== null && $search !== '') {
$query->where('name', 'like', '%'.$search.'%');
}
foreach ($query->get() as $task) {
$options[] = ['value' => (string) $task->getKey(), 'label' => static::recordTitle($task)];
}
return $options;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Validation tetap menggunakan rule exists pada related table, bukan membership terhadap daftar option yang sedang dirender. Daftar option hanya satu page yang dibatasi; key valid yang berada di luar limit tetap merupakan key yang valid. Record yang sudah berada pada relasi ditolak secara terpisah:
POST .../operation=associate { "related": "12" }
→ 422 "That record is already in this relation."2
DissociateAction
use PandaPanel\Actions\Relations\DissociateAction;
DissociateAction::make(string $manager, Model $owner): Action;2
3
| Properti | Nilai |
|---|---|
| Name | dissociate |
| Label | Dissociate |
| Icon | unlink |
| Variant | ActionVariant::Ghost |
| Confirmation | "Dissociate this record?" — "The record is kept but no longer belongs to this one." |
| Success message | Record dissociated. |
| Terlihat ketika | RelationManager::isOneToMany($owner) |
| Diotorisasi oleh | RelationManager::canDissociate($owner, $record) → dissociate pada owner, bersama related record |
Handler mengosongkan foreign key relasi pada child lalu menyimpannya:
$foreignKey = $relation->getForeignKeyName();
$record->setAttribute($foreignKey, null)->save();2
3
POST /{panel}/relations/action
{ "resource": "projects", "record": 7, "relation": "tasks", "action": "dissociate", "related": 12 }
→ tasks.project_id is null, the task still exists, and it leaves this table2
3
Package tidak menyediakan bulk dissociate action bawaan. Jika sebuah relasi membutuhkan behavior tersebut, deklarasikan bulk action biasa:
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
use PandaPanel\Actions\Action;
use PandaPanel\Actions\Enums\ActionVariant;
use Symfony\Component\HttpKernel\Exception\HttpException;
Action::make('dissociate')
->label('Dissociate selected')
->icon('unlink')
->variant(ActionVariant::Destructive)
->requiresConfirmation(heading: 'Dissociate the selected records?')
->successMessage('Selected records dissociated.')
->authorize(static fn (?Model $record): bool => $record === null
|| self::canDissociate($owner, $record))
->bulkAction(static function (Collection $records) use ($owner): void {
foreach ($records as $record) {
if (! self::canDissociate($owner, $record)) {
throw new HttpException(403, 'You may not dissociate every selected record.');
}
}
$foreignKey = self::relation($owner)->getForeignKeyName();
DB::transaction(static function () use ($records, $foreignKey): void {
$records->each(static fn (Model $record) => $record
->setAttribute($foreignKey, null)
->save());
});
});2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
Lakukan authorization pada seluruh record sebelum menulis satu pun, sama seperti DetachBulkAction. Selection yang mengandung satu record terlarang harus tidak mengubah apa pun, bukan mengubah separuh selection lalu gagal di tengah.
Associate tidak memiliki bagian pivot
RelationForm hanya membangun pivot fields untuk many-to-many dan tetap melewatinya pada RelationOperation::Associate. One-to-many tidak memiliki join row untuk ditulis, sehingga pivotForm() pada manager semacam ini hanya akan menghasilkan input yang tidak pernah tersimpan. Lihat Pivot fields.
Mengustomisasi action
DissociateAction::make() mengembalikan PandaPanel\Actions\Action, sehingga seluruh builder method Action tetap dapat digunakan:
use PandaPanel\Actions\Enums\ActionVariant;
use PandaPanel\Actions\Relations\DissociateAction;
DissociateAction::make(self::class, $owner)
->label('Move out of project')
->variant(ActionVariant::Outline)
->requiresConfirmation(
heading: 'Move this task out?',
description: 'The task stays in the backlog, unassigned.',
button: 'Move out',
)
->successMessage('Task moved out of the project.');2
3
4
5
6
7
8
9
10
11
12
AssociateAction dibuat oleh RelationTable::headerActions(), bukan oleh manager, sehingga tidak dapat dikustomisasi melalui pola yang sama. Yang dapat Anda kontrol adalah apa yang ditawarkannya: $title menentukan label dan attachableOptions() menentukan option list.
Hal yang perlu diperhatikan
- Foreign key harus nullable. Framework tidak memeriksa hal ini.
DissociateActionditawarkan pada seluruh one-to-many; jika columnNOT NULL, proses save akan gagal pada database. Jika foreign key memang tidak boleh null, operasi yang lebih jujur adalah delete. HasOnetetap dianggap one-to-many untuk pemeriksaan ini.isOneToMany()mengecekHasOneOrMany, sehingga associate dan dissociate juga ditawarkan padahasOne. Meng-associate record kedua dapat membuat dua row menunjuk owner yang sama; relation kemudian mengembalikan salah satu sesuai urutan database.- Associate dapat mengambil child dari owner lain. Option list hanya mengecualikan record yang sudah berada pada relasi ini. Persempit
attachableOptions()bila perpindahan semacam itu tidak diinginkan. associateAnydandissociateberada pada policy milik owner. Method yang hilang adalah penyebab paling umum tombol associate tidak muncul. DenganPanel::strictAuthorization(), kondisi tersebut menjadi exception, bukan silent deny. Lihat Related record policies.->authorize()melakukan replace, bukan menambah check. Menambahkanauthorize()padaDissociateAction::make()menggantikan checkcanDissociate(). Panggil method manager tersebut di dalam closure Anda jika hanya ingin mempersempit.- Attach dan associate tidak pernah muncul bersamaan. Keduanya memeriksa bentuk relasi pada visibility dan endpoint. Associate yang dikirim ke
belongsToManymenghasilkan 403; attach yang dikirim kehasManyjuga 403. - Dissociate bukan delete. Record tetap hidup tanpa owner.
DeleteRelatedActionadalah operasi yang benar-benar menghapusnya.