Saving BelongsTo Associations (CakePHP 5.1)

Hello,
I have a behavior problem when saving a belongsTo association.

In my project I have a missions table and a contacts table with :

// MissionsTable.php
$this->belongsTo('Contacts')
	->setForeignKey('contact_id');

// missions
//  - id
//  - contact_id
//  - ...

// contacts
//  - id
//  - firstname
//  - lastname
//  - email
//  - ...

I would like to be able to add the contact and modify it if necessary. For example, to be able to add the contact’s email if it is not filled in on the existing entity. So I can’t just edit $mission->contact_id

My problem occurs when I edit a mission that has no contact and add an existing contact.
Saving will create a new contact entity despite the presence of the ID in the data.

// MissionsController.php
public function test($id)
{
	$associated = ['Contacts'];
        
	$mission = $this->Missions->get($id, contain: $associated);
	// $mission->contact = null, because $mission->contact_id = null

	$data = [
		// ...
		'contact' => [
			'id' => '1',
			'firstname' => 'John',
			'lastname' => 'Doe',
			'email' => 'john.doe@example.com',
		]
	];
	
	$mission = $this->Missions->patchEntity($mission, $data, ['associated' => $associated]);
	debug($mission);
	die;
}

debug($mission) result :

object(App\Model\Entity\Mission) id:0 {
 	'id' => (int) 1
	...
 	'contact' => object(App\Model\Entity\Contact) id:1 {
 		'firstname' => 'John'
 		'lastname' => 'Doe'
 		'email' => 'john.doe@example.com'
 		'[new]' => true
		...
 	}
 	'[new]' => false
}

We can see that after the patchEntity(), the contact is a new entity despite the presence of the id in the data.

I found some solutions to my problem, but I’m wondering if the behavior is normal or if there would be another way to not create a new entity with the id in the data.

Looking at the code of \Cake\ORM\Marshaller.php

  1. The ‘contact’ field launches the _mergeAssociation() callback
  2. If the original is null (here, if $mission->contact == null) _mergeAssociation() returns to _marshalAssociation(), then one() because the association type is ‘manyToOne’.
  3. In one() method, a new entity is created without checking the presence of the primary key in $data.

Solution 1 : Edit the Core ORM Marshaller

In _mergeAssociation(), shouldn’t we check for the presence of the primary key in $value before redirecting to _marshalAssociation() which will create a new entity?
Or perhaps upstream, in _buildPropertyMap() shouldn’t we check for the presence of the primary key in $value to set the $original?

// cakephp/src/ORM/Marshaller.php
protected function _buildPropertyMap(array $data, array $options): array
{
	// ...
	if (isset($options['isMerge'])) {
		$callback = function (
			$value,
			EntityInterface $entity,
		) use (
			$assoc,
			$nested,
		): array|EntityInterface|null {
			$options = $nested + ['associated' => [], 'association' => $assoc];
			// edit part
			if (isset($value[$assoc->getBindingKey()])) $original = $this->_table->{$assoc->getClassName()}->get($value[$assoc->getBindingKey()]);
			else $original = $entity->get($assoc->getProperty());

			return $this->_mergeAssociation($original, $assoc, $value, $options);
			// end edit part
	} else {
		// ...
	}
	// ...
}

With the above modification, the behavior reacts well as I want.

Solution 2 : Edit associated entity in afterMarshal()

Otherwise, for now, what I can do is act in MissionsTable::afterMarshal()
Check for the presence of the id in $data and replace the contact entity.
But I need to do this for each belongsTo association.

// MissionsTable.php
public function afterMarshal(
	EventInterface $event,
	EntityInterface $entity,
	ArrayObject $data,
	ArrayObject $options
): void {
	// belongsTo Contacts
	if ($entity->isDirty('contact') && isset($data['contact']['id']) && !empty($data['contact']['id'])) {
		// we do not create a new entity
		$contact_entity = $this->Contacts->get($data['contact']['id']);
		$contact_entity = $this->Contacts->patchEntity($contact_entity, $data['contact']);
		$entity->contact = $contact_entity;
	}
}

Thanks for reading

This will allow you to update the Contact:

        $mission = $this->Missions->patchEntity($mission, $data, [
            'associated' =>
            [
                'Contacts' => [
                    'accessibleFields' => ['id' => true]
                ]
            ]
        ]);

It seemed to me that there was certainly a simpler way :slight_smile:
THANKS !

I had tried to put the contact ID accessible in Model/Entity/Contact.php
but I still saw [new] => true on the contact entity after the patchEntity and I didn’t go any further.

But indeed, after saving, $this->Missions->save($mission);
the $mission->contact entity has kept the id and we have [new] => false.

Thanks for help