Symfony2 form collection not calling addxxx and removexxx even if 'by_reference' => false
Nick Denry’s Question:
I have the Customer entity and two one-to-many relations CustomerPhone and CustomerAddress.
The Customer entity has addPhone/removePhone and addAddress/removeAddress “adders”.
CustomerType collections options has ‘by_reference’ => false for both collections.
Entity functions addPhone/removePhone and addAddress/removeAddress not called after form submitted, so CustomerPhone and CustomerAddress have no parent id after persist.
Why could addPhone/removePhone and addAddress/removeAddress not called on form submit?
UPD 1.
After @Baig suggestion now I have addPhone/removePhone “adders” called, but addAddress/removeAddress not. Can’t get why because they are identical.
# TestCustomerBundle/Entity/Customer.php
/**
* @var string
*
* @ORMOneToMany(targetEntity="CustomerPhone", mappedBy="customerId", cascade={"persist"}, orphanRemoval=true)
*/
private $phone;
/**
* @var string
*
* @ORMOneToMany(targetEntity="CustomerAddress", mappedBy="customerId", cascade={"persist"}, orphanRemoval=true)
*/
private $address;
Same file “adders”
# TestCustomerBundle/Entity/Customer.php
/**
* Add customer phone.
*
* @param Phone $phone
*/
public function addPhone(CustomerPhone $phone) {
$phone->setCustomerId($this);
$this->phone->add($phone);
return $this;
}
/**
* Remove customer phone.
*
* @param Phone $phone customer phone
*/
public function removePhone(CustomerPhone $phone) {
$this->phone->remove($phone);
}
/**
* Add customer address.
*
* @param Address $address
*/
public function addAddress(CustomerAddress $address) {
$address->setCustomerId($this);
$this->address->add($address);
return $this;
}
/**
* Remove customer address.
*
* @param Address $address customer address
*/
public function removeAddress(CustomerAddress $address) {
$this->address->remove($address);
}
Relations:
# TestCustomerBundle/Entity/CustomerPhone.php
/**
* @ORMManyToOne(targetEntity="Customer", inversedBy="phone")
* @ORMJoinColumn(name="customer_id", referencedColumnName="id")
**/
private $customerId;
#TestCustomerBundle/Entity/CustomerAddress.php
/**
* @ORMManyToOne(targetEntity="Customer", inversedBy="address")
* @ORMJoinColumn(name="customer_id", referencedColumnName="id")
**/
private $customerId;
CustomerType form:
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('name')
->add('phone', 'collection', array(
'type' => new CustomerPhoneType(),
'allow_add' => true,
'allow_delete' => true,
'by_reference' => false,
'options' => array('label' => false)
))
->add('address', 'collection', array(
'type' => new CustomerAddressType(),
'allow_add' => true,
'allow_delete' => true,
'by_reference' => false,
'options' => array('label' => false)
))
->add('submit', 'submit')
;
}
Controller.
# TestCustomerBundle/Controller/DefaultController.php
public function newAction(Request $request)
{
$customer = new Customer();
// Create form.
$form = $this->createForm(new CustomerType(), $customer);
// Handle form to store customer obect with doctrine.
if ($request->getMethod() == 'POST')
{
$form->bind($request);
if ($form->isValid())
{
/*$em = $this->get('doctrine')->getEntityManager();
$em->persist($customer);
$em->flush();*/
$request->getSession()->getFlashBag()->add('success', 'New customer added');
}
}
// Display form.
return $this->render('DeliveryCrmBundle:Default:customer_form.html.twig', array(
'form' => $form->createView()
));
}
UPD 2.
Test if addAddress called.
/**
* Add customer address.
*
* @param Address $address
*/
public function addAddress(Address $address) {
jkkh; // Test for error if method called. Nothing throws.
$address->setCustomerId($this);
$this->address->add($address);
}
UPD 3.
CustomerAddressType.php
<?php
namespace DeliveryCrmBundleForm;
use SymfonyComponentFormAbstractType;
use SymfonyComponentFormFormBuilderInterface;
use SymfonyComponentOptionsResolverOptionsResolverInterface;
class CustomerAddressType extends AbstractType
{
/**
* @param FormBuilderInterface $builder
* @param array $options
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('street')
->add('house')
->add('building', 'text', ['required' => false])
->add('flat', 'text', ['required' => false])
;
}
/**
* @param OptionsResolverInterface $resolver
*/
public function setDefaultOptions(OptionsResolverInterface $resolver)
{
$resolver->setDefaults(array(
'data_class' => 'DeliveryCrmBundleEntityCustomerAddress'
));
}
/**
* @return string
*/
public function getName()
{
return 'delivery_crmbundle_customeraddress';
}
}
CustomerPhoneType.php
<?php
namespace DeliveryCrmBundleForm;
use SymfonyComponentFormAbstractType;
use SymfonyComponentFormFormBuilderInterface;
use SymfonyComponentOptionsResolverOptionsResolverInterface;
class CustomerPhoneType extends AbstractType
{
/**
* @param FormBuilderInterface $builder
* @param array $options
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('number')
;
}
/**
* @param OptionsResolverInterface $resolver
*/
public function setDefaultOptions(OptionsResolverInterface $resolver)
{
$resolver->setDefaults(array(
'data_class' => 'DeliveryCrmBundleEntityCustomerPhone'
));
}
/**
* @return string
*/
public function getName()
{
return 'phone';
}
}
This answer corresponds to Symfony 3, but I am sure this applies to Symfony 2 as well. Also this answer is more as a reference than addressing OP’s issue in particular (which I am not to clear)
On ..Symfony/Component/PropertyAccess/PropertyAccessor.php
the method writeProperty
is responsible for calling either setXXXXs
or addXXX
& removeXXXX
methods.
So here is order on which it looks for the method:
-
If the entity is
array
or instance ofTraversable
(whichArrayCollection
is) then pair ofaddEntityNameSingular()
-
removeEntityNameSingular()
Source for reference:
if (is_array($value) || $value instanceof Traversable) { $methods = $this->findAdderAndRemover($reflClass, $singulars); if (null !== $methods) { $access[self::ACCESS_TYPE] = self::ACCESS_TYPE_ADDER_AND_REMOVER; $access[self::ACCESS_ADDER] = $methods[0]; $access[self::ACCESS_REMOVER] = $methods[1]; } }
-
If not then:
setEntityName()
entityName()
__set()
$entity_name
(Should be public)-
__call()
Source for reference:
if (!isset($access[self::ACCESS_TYPE])) { $setter = 'set'.$camelized; $getsetter = lcfirst($camelized); // jQuery style, e.g. read: last(), write: last($item) if ($this->isMethodAccessible($reflClass, $setter, 1)) { $access[self::ACCESS_TYPE] = self::ACCESS_TYPE_METHOD; $access[self::ACCESS_NAME] = $setter; } elseif ($this->isMethodAccessible($reflClass, $getsetter, 1)) { $access[self::ACCESS_TYPE] = self::ACCESS_TYPE_METHOD; $access[self::ACCESS_NAME] = $getsetter; } elseif ($this->isMethodAccessible($reflClass, '__set', 2)) { $access[self::ACCESS_TYPE] = self::ACCESS_TYPE_PROPERTY; $access[self::ACCESS_NAME] = $property; } elseif ($access[self::ACCESS_HAS_PROPERTY] && $reflClass->getProperty($property)->isPublic()) { $access[self::ACCESS_TYPE] = self::ACCESS_TYPE_PROPERTY; $access[self::ACCESS_NAME] = $property; } elseif ($this->magicCall && $this->isMethodAccessible($reflClass, '__call', 2)) { // we call the getter and hope the __call do the job $access[self::ACCESS_TYPE] = self::ACCESS_TYPE_MAGIC; $access[self::ACCESS_NAME] = $setter; } else { $access[self::ACCESS_TYPE] = self::ACCESS_TYPE_NOT_FOUND; $access[self::ACCESS_NAME] = sprintf( 'Neither the property "%s" nor one of the methods %s"%s()", "%s()", '. '"__set()" or "__call()" exist and have public access in class "%s".', $property, implode('', array_map(function ($singular) { return '"add'.$singular.'()"/"remove'.$singular.'()", '; }, $singulars)), $setter, $getsetter, $reflClass->name ); } }
To answer OP’s issue, based on the above mentioned information, the PropertyAccessor class of symfony is not able to read your addXX
and removeXX
method properly. The potential reason might be that is not identified as array
or ArrayCollection
which has to be done from the constructor of the entity
public function __construct() {
$this->address = new ArrayCollection();
// ....
}