Search code examples
databaseformsentitysymfonyarraycollection

Symfony3 : ArrayCollection has only the last item added


I have an entity Product. My product can have multiple names in different languages. A name in french, a name in english, etc. I don't want to use an automatic translation.

The user will have to write the names in the Product form and select the corresponding language. He can add so many names as he wants thanks to an Add button.

All the languages are created by the admin user (in another form). So, Language is also an Entity which have a name (ex: English) and a code (ex: EN).

So, ProductType is my primary form, and ProductNameType is my "collection" form.

When a user creates a new product with 2 names for example (one in french and another in english), the product is saved in my database and 2 names are also created and saved in another table.

At this point, everything works well. My addAction() is good, the product and the corresponding names are saved in database.

But, I have a problem on my editAction(), when I show my form pre-filled. Only the last added product name is present in my collection array...

I don't understand what I'm doing wrong. The names are in my database, the product too, so why I get only the last name in the ArrayCollection ?

Entity Product.php

namespace AppBundle\Entity;

use Doctrine\ORM\Mapping as ORM;
use Doctrine\Common\Collections\ArrayCollection;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;

/**
 * @ORM\Table(name="modele")
 * @ORM\Entity(repositoryClass="ProductRepository")
 * @UniqueEntity(fields="code", message="Product code already exists")
 */
class Product
{
    /**
     * @ORM\Column(name="Modele_Code", type="string", length=15)
     * @ORM\Id
     * @Assert\NotBlank()
     * @Assert\Length(max=15, maxMessage="The code cannot be longer than {{ limit }} characters")
     */
    private $code;

    /**
     * @ORM\OneToMany(targetEntity="ProductNames", mappedBy="product", cascade={"persist", "remove"})
     */
    private $names;

    /**
     * Constructor
     */
    public function __construct()
    {
        $this->names = new ArrayCollection();
    }

    /**
     * Set code
     *
     * @param string $code
     *
     * @return Product
     */
    public function setCode($code)
    {
        $this->code = $code;

        return $this;
    }

    /**
     * Get code
     *
     * @return string
     */
    public function getCode()
    {
        return $this->code;
    }

    /**
     * Get names
     *
     * @return ArrayCollection
     */
    public function getNames()
    {
      return $this->names;
    }

    /**
     * Add names
     *
     * @param ProductNames $names
     *
     * @return Product
     */
    public function addName(ProductNames $names)
    {
        $names->setCode($this->getCode());
        $names->setProduct($this);

        if (!$this->getNames()->contains($names)) {
            $this->names->add($names);
        }

        return $this;
    }

    /**
     * Remove names
     *
     * @param ProductNames $names
     */
    public function removeName(ProductNames $names)
    {
        $this->names->removeElement($names);
    }
}

Entity ProductNames.php

namespace AppBundle\Entity;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;

/**
 * @ORM\Table(name="modele_lib")
 * @ORM\Entity(repositoryClass="ModelTextsRepository")
 * @UniqueEntity(fields={"code","language"}, message="A name in this language already exists for this product")
 */
class ProductNames
{
    /**
     * @ORM\Column(name="Modele_Code", type="string", length=15)
     * @ORM\Id
     */
    private $code;

    /**
     * @ORM\ManyToOne(targetEntity="Product", inversedBy="names")
     * @ORM\JoinColumn(name="Modele_Code", referencedColumnName="Modele_Code")
     */
    private $product;

    /**
     * @ORM\Column(name="Langue_Code", type="string", length=2)
     */
    private $language;

    /**
     * @ORM\Column(name="Modele_Libelle", type="string", length=50)
     * @Assert\NotBlank()
     */
    private $name;

    /**
     * Set code
     *
     * @param string $code
     *
     * @return ProductNames
     */
    public function setCode($code)
    {
        $this->code = $code;

        return $this;
    }

    /**
     * Get code
     *
     * @return string
     */
    public function getCode()
    {
        return $this->code;
    }

     /**
      * Set product
      *
      * @param Product $product
      *
      * @return ProductNames
      */
    public function setProduct(Model $product)
    {
        $this->product = $product;

        return $this;
    }

    /**
     * Get product
     *
     * @return Product
     */
    public function getProduct()
    {
        return $this->product;
    }

    /**
     * Set language
     *
     * @param string $language
     *
     * @return ProductNames
     */
    public function setLanguage($language)
    {
        $this->language = $language;

        return $this;
    }

    /**
     * Get language
     *
     * @return string
     */
    public function getLanguage()
    {
        return $this->language;
    }

    /**
     * Set name
     *
     * @param string $name
     *
     * @return ProductNames
     */
    public function setName($name)
    {
        $this->name = $name;

        return $this;
    }

    /**
     * Get name
     *
     * @return string
     */
    public function getName()
    {
        return $this->name;
    }
}

Form ProductType.php

namespace AppBundle\Form;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
// use Doctrine\ORM\EntityRepository;

class ProductType extends AbstractType
{

    public function buildForm(FormBuilderInterface $builder, array $options)
    {

        $recordId = $options['data']->getCode();    // product code

        // default options for names
        $namesOptions = array(
            'entry_type'   => ProductNamesType::class,
            'entry_options' => array('languages' => $options['languages']),
            'allow_add'     => true,
            'allow_delete'  => true,
            'prototype'     => true,
            'label'         => false,
            'by_reference' => false
        );

        // case edit product
        if (!empty($recordId)) {
            $namesOptions['entry_options']['edit'] = true;
        }

        $builder
            ->add('code',       TextType::class, array(
                'attr'              => array(
                    'size'          => 15,
                    'maxlength'     => 15,
                    'placeholder'   => 'Ex : LBSKIN'
                ),
            ))

            ->add('names',      CollectionType::class, $namesOptions)

            ->add('save',       SubmitType::class, array(
                'attr'          => array('class' => 'button-link save'),
                'label'         => 'Validate'
            )
        );

        // Edit case : add delete button
        if (!empty($recordId)) {
            $builder->add('delete', SubmitType::class, array(
                'attr'      => array('class' => 'button-link delete'),
                'label'     => 'Delete'
            ));
        }
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults(array(
            'data_class' => 'AppBundle\Entity\Product',
            'languages'  => null
        ));
    }
}

Form ProductNamesType.php

namespace AppBundle\Form;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Ivory\CKEditorBundle\Form\Type\CKEditorType;
use Doctrine\ORM\EntityRepository;

class ProductNamesType extends AbstractType
{

    public function buildForm(FormBuilderInterface $builder, array $options)
    {

        // Language codes list
        $choices = array();
        foreach ($options['languages'] as $lang) {
            $code = $lang->getCode();
            $choices[$code] = $code;
        }

        $builder
            ->add('name',           TextType::class)

            ->add('language',       ChoiceType::class, array(
                'label'             => 'Language',
                'placeholder'       => '',
                'choices'           => $choices
            ))
        ;
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults(array(
            'data_class' => 'AppBundle\Entity\ProductNames',
            'languages'  => null,
            'edit'       => false
        ));
    }

}

ProductController.php (see editAction to find my problem).

If I print $form->getData() or $product->getNames() in addAction() after form submit, I get all my data, everything is ok for me.

namespace AppBundle\Controller;

use AppBundle\Form\ProductType;
use AppBundle\Entity\Product;
use AppBundle\Entity\ProductNames;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;
use Doctrine\Common\Collections\ArrayCollection;

class ProductController extends Controller
{

   /**
    * @Route("/products/add", name="product_add")
    */
    public function addAction(Request $request) {

      // build the form
      $em = $this->getDoctrine()->getManager();
      $languages = $em->getRepository('AppBundle:Language')->findAllOrderedByCode();


      $product = new Product();

      $form = $this->createForm(ProductType::class, $product, array(
            'languages' => $languages
        ));

      // handle the submit
      $form->handleRequest($request);
      if ($form->isSubmitted() && $form->isValid()) {

            // save the product
            $em->persist($product);

            foreach($product->getNames() as $names){
                $em->persist($names);
            }

            $em->flush();

            /*** here, everything is working ***/

            // success message
            $this->addFlash('notice', 'Product has been created successfully !');

            // redirection
            return $this->redirectToRoute('product');
      }

      // show form
      return $this->render('products/form.html.twig', array(
         'form' => $form->createView()
      ));
   }

   /**
    * @Route("/products/edit/{code}", name="product_edit")
    */
   public function editAction($code, Request $request) {

      // get product from database
      $em = $this->getDoctrine()->getManager();
      $product = $em->getRepository('AppBundle:Product')->find($code);
      $languages = $em->getRepository('AppBundle:Language')->findAllOrderedByCode();

      // product doesn't exist
      if (!$product) {
         throw $this->createNotFoundException('No product found for code '. $code);
      }

        $originalNames = new ArrayCollection();

        /*** My PROBLEM IS HERE ***/
        // $product->getNames() returns only one name : the last added
        foreach ($product->getNames() as $names) {
           $originalNames->add($names);
        }

        // My form shows only one "name block" with the last name added when the user created the product.

      // build the form with product data
      $form = $this->createForm(ProductType::class, $product, array(
            'languages' => $languages
        ));

      // form POST
      $form->handleRequest($request);
      if ($form->isSubmitted() && $form->isValid()) {

            // ...
      }

      // show form
      return $this->render('products/form.html.twig', array(
         'form'      => $form->createView(),
         'product_code' => $code
      ));
   }
}

Solution

  • The problem may be with your ProductNames entity. You have marked code as primary key (with @ORM\Id), a Product will have multiple ProductNames and they all cannot have code as primary key since a primary key needs to be unique. I would suggest using a composite primary key by adding @ORM\Id annotation to langauge.

    class ProductNames
    {
        /**
         * @ORM\Column(name="Modele_Code", type="string", length=15)
         * @ORM\Id
         */
        private $code;
    
        /**
         * @ORM\Column(name="Langue_Code", type="string", length=2)
         * @ORM\Id
         */
        private $language;
    
        // ...
    }
    

    You'll have to update/re-create your database for the composite key to take effect.

    Hope this helps.