So I've spent around 5 or 6 hours today battling with Symfony2 forms and am at the point where I'd like some advice from other members of the community. I've tried over 3 different methods to achieve what I'm after and had no success. I've read through the docs, googled everything, asked others, and I'm only a little bit better off than when I started.
My use case
I'm building a system where you can order tickets. But the core problem is how to design the order part of the system.
- a Ticket has a name, and start and end dates where it's available (other stuff as well but lets keep the example simple.
- an Order may have multiple Tickets selected and for each Ticket there is a quantity.
- an Order has a Customer. This part is fine and works dandy!
After reading around and trying different things, I gathered that to represent the Order's Ticket and quantity, I needed another entity OrderTicket corresponds to an OrderItem from https://github.com/beberlei/AcmePizzaBundle and the Pizza is my Ticket.
- an OrderTicket has a Ticket and quantity.
On my order page where an order is created, I want the following:
- a form for Customer details - name, email, address. This part works fine.
- a form for the Tickets. I want the Ticket name displayed, in a textbox or even a string; not in a select box (which is what is happening now). I want the quantity to be specified next to the ticket name. If there's no quantity set, this means that ticket is not selected.
- the Tickets should be filtered where they are available depending on todays date - this is achieved elsewhere (in the backend admin where they're created) by using a custom repository method on a form type with a query builder closure.
My back end
The Order/OrderTicket/Ticket design is largely based on https://github.com/beberlei/AcmePizzaBundle
Ticket
/**
* @ORM\Entity(repositoryClass="Foo\BackendBundle\Entity\TicketsRepository")
* @ORM\HasLifecycleCallbacks
* @ORM\Table(name="tickets")
*/
class Tickets
{
// id fields and others
/**
* @Assert\NotBlank
* @ORM\Column(type="string", nullable=true)
*/
protected $name;
/**
* @ORM\Column(type="date", name="available_from", nullable=true)
*/
protected $availableFrom;
/**
* @ORM\Column(type="date", name="available_to", nullable=true)
*/
protected $availableTo;
}
OrderTicket
/**
* @ORM\Table()
* @ORM\Entity
*/
class OrderTicket
{
// id field
/**
* @ORM\Column(name="quantity", type="integer")
*/
private $quantity;
/**
* @ORM\ManyToOne(targetEntity="Tickets")
*/
protected $ticket;
/**
* @ORM\ManyToOne(targetEntity="Orders", inversedBy="tickets")
*/
protected $order;
// getters and setters for quantity, ticket and order
}
Order
/**
* @ORM\Entity
* @ORM\HasLifecycleCallbacks
* @ORM\Table(name="orders")
*/
class Orders
{
// id field and other stuff
/**
* @ORM\OneToMany(targetEntity="OrderTicket", mappedBy="order", cascade={"persist"})
**/
protected $tickets;
/**
* @ORM\ManyToOne(targetEntity="Customer", cascade={"persist"})
*/
protected $customer;
public function __construct()
{
$this->tickets = new \Doctrine\Common\Collections\ArrayCollection();
}
// getters, setters, add for Tickets and Customer
}
Customer
/**
* @ORM\Table()
* @ORM\Entity
*/
class Customer
{
// id, name, email, address fields
}
This creates a schema like so (table naming differences are from auto generation):
CREATE TABLE `tickets` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL,
`available_from` date DEFAULT NULL,
`available_to` date DEFAULT NULL,
PRIMARY KEY (`id`)
);
CREATE TABLE `Customer` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`email` varchar(255) COLLATE utf8_unicode_ci NOT NULL,
`name` varchar(255) COLLATE utf8_unicode_ci NOT NULL,
`address` longtext COLLATE utf8_unicode_ci NOT NULL,
PRIMARY KEY (`id`)
);
CREATE TABLE `OrderTicket` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`ticket_id` int(11) DEFAULT NULL,
`order_id` int(11) DEFAULT NULL,
`quantity` int(11) NOT NULL,
PRIMARY KEY (`id`)
);
CREATE TABLE `orders` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`customer_id` int(11) DEFAULT NULL,
PRIMARY KEY (`id`)
);
Forms
class CustomerType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('email')
->add('name')
->add('address')
;
}
public function setDefaultOptions(OptionsResolverInterface $resolver)
{
$resolver->setDefaults(array(
'data_class' => 'Foo\BackendBundle\Entity\Customer'
));
}
public function getName()
{
return 'foo_backendbundle_customertype';
}
}
class OrderTicketType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('quantity', 'integer')
->add('ticket')
;
}
public function setDefaultOptions(OptionsResolverInterface $resolver)
{
$resolver->setDefaults(array(
'data_class' => 'Foo\BackendBundle\Entity\OrderTicket'
));
}
public function getName()
{
return 'foo_backendbundle_ordertickettype';
}
}
class OrdersType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('customer', new CustomerType())
->add('tickets', 'collection', array(
'type' => new OrderTicketType(),
'allow_add' => true,
'allow_delete' => true,
'prototype' => true,
))
;
}
public function setDefaultOptions(OptionsResolverInterface $resolver)
{
$resolver->setDefaults(array(
'data_class' => 'Foo\BackendBundle\Entity\Orders',
));
}
public function getName()
{
return 'foo_backendbundle_orderstype';
}
}
The form
<form action="{{ path('index') }}" method="post" {{ form_enctype(form) }}>
<h3>Tickets</h3>
{{ form_errors(form) }}
<table>
<thead>
<tr>
<td>Ticket</td>
<td>Quantity</td>
</thead>
<tbody>
{% for ticketrow in form.tickets %}
<tr>
<td>{{ form_widget(ticketrow.ticket) }}</td>
<td>{{ form_widget(ticketrow.quantity) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<h3>Customer</h3>
{% for customer in form.customer %}
{{ form_row(customer) }}
{% endfor %}
</form>
And finally the controller
class DefaultController extends Controller
{
/**
* @Route("/", name="index")
* @Template()
*/
public function indexAction(Request $request)
{
$em = $this->getDoctrine()->getManager();
// IMPORTANT - the Tickets are prefiltered for active Tickets, these have to be injected into the Order atm. In other places I use this method on the query builder
$tickets = $em->getRepository('FooBackendBundle:Tickets')->findActive();
// check no tickets
$order = new Orders();
// To prepopulate the order with the available tickets, we have to do it like this, due to it being a collection,
// rather than using the forms query_builder like everywhere else
foreach($tickets as $ticket) {
$ot = new OrderTicket();
$ot->setTicket($ticket);
$ot->setQuantity(0);
$ot->setOrder($order);
$order->addTicket($ot);
}
$form = $this->createForm(new OrdersType(), $order);
if ($request->isMethod('POST')) {
$form->bind($request);
// IMPORTANT here I have to remove the previously added Tickets where the quantity is 0 - as they're not wanted in the Order. Is there a better way to do this?
// if the quantity of Ticket is 0, do not add to order
// note we use the validation callback in Order to check total quantity of OrderTickets is > 0
$order->removeTicketsWithNoQuantity();
if ($form->isValid()) {
$em->persist($order);
$em->flush();
return $this->redirect($this->generateUrl('order_show', array('id' => $order->getId())));
}
}
return array('form' => $form->createView());
}
}
Summary
This works and will save the Order correctly, but I'm not sure it is the correct way to do what I want, and it does not display as I want.
You can see below in the images how it looks and how the Order goes through. It is worth noting that in each of the Ticket drop downs is the rest of the Tickets but which are not active.
The Order page:
The Order summary page after save:
The 3 Tickets displayed are the ones that have been filtered, and I only want these Tickets on the form. I ONLY WANT TO SEE THE TICKET NAME, NOT AN EDITABLE DROP DOWN.
The core problem is that they are presented as editable drop downs. I may just want a text string of the Ticket name, or maybe even the Ticket price in the future. I'm not sure how to achieve this. I know that the ticket field and relationship must be rendered somehow so that it can be bound in the controller. So basically I want to be able to use the Ticket entity and it's fields on the same row as the quantity text box.
So let's step out of the crapstorm of Symfony2's forms and put this in perspective - in the normal world, obviously I'd just retrieve the Tickets, then for each Ticket, I'd print the Ticket name, any other stuff I wanted, a hidden Ticket id, then an input for the Ticket quantity. Back into SF2 a little - I guess I need the Ticket entity available whilst looping the OrderTicket collection.
Please help me!