Attach dan Detach
Attach menghubungkan existing record ke relasi many-to-many dengan menulis pivot row; detach menghapus pivot row tersebut tetapi membiarkan kedua record utama tetap ada. Action ini hanya ditawarkan ketika memang terdapat join row yang dapat dibuat dan dihapus — yaitu BelongsToMany atau MorphToMany. "Detach" terhadap child hasMany sebenarnya berarti mengosongkan foreign key miliknya, yaitu keputusan berbeda dengan nama berbeda: lihat Associate dan dissociate.
Manager yang melakukan attach dan detach
<?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\DetachAction;
use PandaPanel\Actions\Relations\DetachBulkAction;
use PandaPanel\Actions\Relations\EditRelatedAction;
use PandaPanel\Resources\RelationManager;
use PandaPanel\Tables\Columns\TextColumn;
use PandaPanel\Tables\TableSchema;
final class LabelsRelationManager extends RelationManager
{
protected static string $relationship = 'labels';
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),
DetachAction::make(self::class, $owner),
])
->bulkActions([
DetachBulkAction::make(self::class, $owner),
]);
}
}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
Owner model mendeklarasikan relation:
public function labels(): BelongsToMany
{
return $this->belongsToMany(Label::class);
}2
3
4
Tombol attach muncul sendiri di atas table. AttachAction adalah header action yang di-resolve oleh RelationTable, bukan sesuatu yang perlu ditulis pada daftar manager. Action detach tetap Anda deklarasikan sendiri karena keputusan apakah sebuah row boleh dilepas satu per satu, secara bulk, atau tidak sama sekali adalah keputusan milik table tersebut.
AttachAction
use PandaPanel\Actions\Relations\AttachAction;
AttachAction::make(string $resource, string $manager, Model $owner): Action;2
3
| Properti | Nilai |
|---|---|
| Name | attach |
| Label | Attach {manager title} |
| Icon | link |
| Variant | ActionVariant::Outline |
| Type | form — membuka dialog yang diambil dari relation form endpoint |
| Terlihat ketika | RelationManager::isManyToMany($owner) |
| Diotorisasi oleh | RelationManager::canAttach($owner) → attachAny pada owner |
Dialog adalah relation form untuk operation attach: sebuah select yang menunjuk record yang akan dihubungkan, ditambah pivot fields yang dideklarasikan manager.
GET /{panel}/relations/form?resource=projects&record=7&relation=labels&operation=attach
POST /{panel}/relations/form?resource=projects&record=7&relation=labels&operation=attach
{ "related": "4", "pivot": { "role": "primary" } }2
3
Kedua sisi ditentukan server: option list dibangun dari RelationManager::attachableOptions() dan pivot values divalidasi terhadap pivotForm(). Key yang tidak pernah ditawarkan atau pivot column yang tidak pernah dideklarasikan sama-sama ditolak alih-alih ditulis.
Record apa yang dapat di-attach
public static function attachableOptions(
Model $owner,
?string $search = null,
int $limit = 50,
): array; // list<array{value: string, label: string}>2
3
4
5
Seluruh related record yang belum berada dalam relasi, diberi label menggunakan recordTitle(), diurutkan berdasarkan title attribute, dan dibatasi oleh $limit. Select bersifat searchable, sehingga record yang berada di luar page pertama tetap dapat ditemukan melalui options endpoint:
GET /{panel}/options?resource=projects&record=7&relation=labels&operation=attach&field=related&search=urg
{ "options": [ { "value": "12", "label": "Urgent" } ] }2
Record yang sudah berada di relasi tidak pernah muncul dalam daftar. Menawarkannya hanya akan menghasilkan attach yang langsung ditolak begitu dipilih.
Mencoba attach record yang sudah ter-attach
Pemeriksaan dilakukan pada controller, bukan melalui form rule. Jika memakai rule, daftar seluruh key yang sudah terhubung harus dibawa ke validation dan daftar tersebut terus tumbuh bersama relasi. Satu indexed lookup memberikan jawaban yang sama dengan biaya lebih masuk akal.
POST .../operation=attach { "related": "4" }
→ 422 "That record is already in this relation."2
DetachAction
use PandaPanel\Actions\Relations\DetachAction;
DetachAction::make(string $manager, Model $owner): Action;2
3
| Properti | Nilai |
|---|---|
| Name | detach |
| Label | Detach |
| Icon | unlink |
| Variant | ActionVariant::Ghost |
| Confirmation | "Detach this record?" — "The record itself is kept; only the link to it is removed." |
| Success message | Record detached. |
| Terlihat ketika | RelationManager::isManyToMany($owner) |
| Diotorisasi oleh | RelationManager::canDetach($owner, $record) → detach pada owner, bersama record |
Handler menjalankan BelongsToMany::detach($record->getKey()): join row dihapus, kedua record utama tetap ada.
POST /{panel}/relations/action
{ "resource": "projects", "record": 7, "relation": "labels", "action": "detach", "related": 4 }2
Teks konfirmasi sengaja menjelaskan hal yang benar-benar terjadi karena kata "remove" dapat berarti melepas hubungan atau menghapus record. Inilah perbedaan utama dengan DeleteRelatedAction, yang menghapus record related itu sendiri.
DetachBulkAction
use PandaPanel\Actions\Relations\DetachBulkAction;
DetachBulkAction::make(string $manager, Model $owner): Action;2
3
| Properti | Nilai |
|---|---|
| Name | detach |
| Label | Detach selected |
| Icon | unlink |
| Variant | ActionVariant::Destructive |
| Confirmation | "Detach the selected records?" |
| Success message | Selected records detached. |
| Terlihat ketika | RelationManager::isManyToMany($owner) |
| Authorization tanpa record | RelationManager::canAttach($owner) — collective gate yang diperiksa endpoint terlebih dahulu |
| Authorization per record | RelationManager::canDetach($owner, $record) di dalam handler |
POST /{panel}/relations/bulk
{ "resource": "projects", "record": 7, "relation": "labels", "action": "detach", "records": [4, 5] }2
Seluruh record di-authorize sebelum satu pivot row pun dihapus dan seluruh set berjalan dalam satu DB::transaction(). Selection yang berisi satu record terlarang tidak melepas record mana pun, bukan melepas record yang diizinkan lalu gagal setengah jalan. Record yang ditolak menghasilkan 403 untuk request secara keseluruhan.
Transaction ini eksplisit terlepas dari setting database transaction milik Panel. "Semua atau tidak sama sekali" adalah bagian dari behavior action yang diotorisasi, bukan default yang kebetulan diwarisi.
Pivot columns saat attach
public static function pivotForm(FormSchema $schema, Model $owner): FormSchema
{
return $schema->schema([
TextInput::make('role')->maxLength(50),
]);
}2
3
4
5
6
Pivot field dirender, divalidasi, dan dikirim di bawah namespace pivot., lalu ditulis bersama pemanggilan attach(). Hanya field yang dideklarasikan yang dipersist:
{ "related": "4", "pivot": { "role": "primary", "smuggled": "value" } }
→ the join row has role = primary, and no smuggled column is written2
Penjelasan lengkap ada di Pivot fields.
Mengustomisasi action
Setiap make() mengembalikan PandaPanel\Actions\Action, sehingga builder method biasa tetap dapat digunakan:
use PandaPanel\Actions\Enums\ActionVariant;
use PandaPanel\Actions\Relations\DetachAction;
DetachAction::make(self::class, $owner)
->label('Remove label')
->icon('x')
->variant(ActionVariant::Outline)
->requiresConfirmation(
heading: 'Remove this label?',
description: 'The label stays in the library.',
button: 'Remove',
)
->successMessage('Label removed.');2
3
4
5
6
7
8
9
10
11
12
13
AttachAction tidak dapat dikustomisasi dengan cara tersebut karena manager tidak pernah membuatnya — action dibangun oleh RelationTable::headerActions(). Yang dapat dikontrol adalah daftar yang ditawarkan: $recordTitleAttribute menentukan label, sedangkan attachableOptions() dapat di-override pada manager.
/**
* @return list<array{value: string, label: string}>
*/
public static function attachableOptions(Model $owner, ?string $search = null, int $limit = 50): array
{
return array_values(array_filter(
parent::attachableOptions($owner, $search, $limit),
static fn (array $option): bool => $option['label'] !== 'Internal',
));
}2
3
4
5
6
7
8
9
10
Hal yang perlu diperhatikan
->authorize()melakukan replace, bukan menambahkan check. Menambahkannya keDetachAction::make()membuang checkcanDetach(). Panggil method manager tersebut di dalam closure jika Anda hanya ingin mempersempit aturan.attachAnydandetachberada pada policy milik owner. Apakah sebuah label boleh dipasang pada project adalah urusan project, bukan label. Method yang hilang adalah penyebab umum tombol attach tidak muncul. Lihat Related record policies.MorphToManydianggap many-to-many. Class tersebut meng-extendBelongsToMany, sehingga satu pemeriksaan mencakup keduanya.- Menyembunyikan action bukan yang mengamankannya.
isManyToMany()digunakan pada visibility dan endpoint. Attach yang dikirim kehasManymenghasilkan 403 walaupun tombolnya tidak pernah dirender. - Detach bukan delete. Jika related record memang tidak memiliki hidup di luar relasi,
DeleteRelatedActionadalah action yang lebih jujur. - Pivot column juga harus dideklarasikan pada relation Eloquent. Tanpa
->withPivot('role')pada relation milik owner model, column memang dapat ditulis tetapi tidak dibaca kembali ke$record->pivot. - Bulk endpoint menghasilkan 404 untuk seluruh request jika ada key di luar relasi. Key di luar relasi menghilang dari query; count check mengubah kondisi tersebut menjadi failure eksplisit, bukan partial operation.