Associate And Dissociate
Associating adopts an existing record into a one-to-many relation by writing its foreign key; dissociating removes it by nulling that key. They are the hasMany/hasOne answer to attach and detach: there is no join row to create or destroy, so the child simply changes whom it belongs to. Each pair is hidden for the shape the other belongs to, so a relation offers one way to bring in an existing record, never two.
A manager that associates and dissociates
<?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
with a nullable foreign key on the child:
Schema::table('tasks', function (Blueprint $table): void {
$table->foreignId('project_id')->nullable()->constrained()->nullOnDelete();
});2
3
The associate button appears above the table by itself — AssociateAction is a header action resolved by RelationTable, not something the manager declares. DissociateAction is a record action you place yourself.
AssociateAction
use PandaPanel\Actions\Relations\AssociateAction;
AssociateAction::make(string $resource, string $manager, Model $owner): Action;2
3
| Property | Value |
|---|---|
| Name | associate |
| Label | Associate {manager title} |
| Icon | link |
| Variant | ActionVariant::Outline |
| Type | form — opens a dialog fetched from the relation form endpoint |
| Visible when | RelationManager::isOneToMany($owner) |
| Authorized by | RelationManager::canAssociate($owner) → associateAny on the owner |
The dialog holds one field: a searchable select naming the record to adopt.
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
The write is $relation->save($related) on a HasOneOrMany, which is what sets the foreign key — and the morph type when there is one:
$related = $relation->getRelated()->newQuery()->find($key);
if ($related !== null) {
$relation->save($related);
}2
3
4
5
What may be associated
The options come from RelationManager::attachableOptions(), the same method the attach dialog uses: every record of the related model that is not already in this relation, capped at 50 and searchable through the options endpoint.
GET /{panel}/options?resource=projects&record=7&relation=tasks&operation=associate&field=related&search=orph"Not already in this relation" is not the same as "belongs to nobody". A child currently owned by another record is offered, and associating it moves it — which is what adopting an existing record means. Narrow attachableOptions() on the manager if only orphans should be offered:
/**
* @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 is still exists on the related table rather than membership of the rendered list — the list is one bounded page, and a real key that sorted past the limit is still a real key. A record already in the relation is refused separately:
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
| Property | Value |
|---|---|
| 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. |
| Visible when | RelationManager::isOneToMany($owner) |
| Authorized by | RelationManager::canDissociate($owner, $record) → dissociate on the owner, with the record |
The handler nulls the relation's foreign key on the child and saves it:
$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
There is no bulk dissociate action in the package. A relation that needs one declares an ordinary bulk action:
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
Authorize every record before writing any of them, exactly as DetachBulkAction does: a selection containing one forbidden record should change nothing rather than half of it.
Associate has no pivot half
RelationForm builds pivot fields only for a many-to-many, and skips them for RelationOperation::Associate even then. A one-to-many has no join row to write, so a pivotForm() on such a manager would render inputs that save nothing. See Pivot fields.
Customizing the actions
DissociateAction::make() returns a PandaPanel\Actions\Action, so the builder methods apply:
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 is built by RelationTable::headerActions() and is not constructed by the manager, so it is not customizable the same way. What it offers is: $title sets its label, and attachableOptions() decides its option list.
Gotchas
- The foreign key must be nullable. Nothing in the framework checks it:
DissociateActionis offered for any one-to-many, and on aNOT NULLcolumn the save fails at the database. Where the column cannot be null, the honest operation is a delete. HasOneis a one-to-many too.isOneToMany()is a check forHasOneOrMany, so associate and dissociate are offered on ahasOne. Associating a second record there writes a second row pointing at the owner, and the relation then returns whichever the database orders first.- Associate can take a child from another owner. The option list excludes only records already in this relation. Narrow
attachableOptions()when that is not what you mean. associateAnyanddissociatelive on the owner's policy. A missing method is the usual cause of an associate button that never appears — and underPanel::strictAuthorization()it is an exception rather than a silent deny. See Related record policies.->authorize()replaces the built-in check. Chaining it ontoDissociateAction::make()throws awaycanDissociate(); call it inside your own closure if you narrow it.- Attach and associate cannot both appear. Each checks the relation's shape, in the action's visibility and at the endpoint: an associate posted against a
belongsToManyis 403, and an attach posted against ahasManyis 403. - Dissociating does not delete. The record survives owning nothing.
DeleteRelatedActionis the one that removes it.