So you love Angular and you love Symfony, but you love Symfony's form builder more than you love Angular. Well that was me, but I wanted to save time making validations in Javascript so I decided to use AngularJS for it. Through many quirks and about a week of working I finally have something hopefully workable.

EDIT: I have reworked this tutorial on 9/2/2015 with MANY improvements!

Coming soon: Github with bundled example

Versions:
Symfony 2.7
Angular 1.4.4
jQuery 1.11

I would say this requires moderate knowledge of Symfony since we will be using Twig templates, custom Twig filter, and custom form extension for our validations. But don't fret, once you have this and you have your collections templates set up, you're off!

What we're going to do:

  1. Create our custom form extension for angular html5 validation and add it to our form builder
  2. Create a custom Twig filter that is the equivelant of php's "addslashes()" function
  3. Create our Twig form block override template
  4. Render out our form with our new Twig overrides
  5. Render out our form collections and sub collections
  6. Add AngularJS custom module to our page

You'll need an existing form type, and if you want to use collections, you'll have to set those up correctly as well (using 'collection' field type and nested form). For more information on how to set this up, visit the awesome Symfony help doc here.

Custom Form Option Extension

We need a custom form option extension so we can add regular expression HTML5 pattern attributes to our input fields. We could just use the attr option, but we want a custom error message to display in AngularJS if our input doesn't validate. The best solution to this is a custom form extension. This way we can validate a zip code, phone number, state abbreviation, or anything else in regular expression with a custom error message.

For this, we just need a form extension class and a service in order to start using our new form option.

src/AppBundle/Form/Extension/AngularValidationExtension.php

namespace AppBundle\Form\Extension;

use Symfony\Component\Form\AbstractTypeExtension;
use Symfony\Component\Form\FormView;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\OptionsResolver\OptionsResolverInterface;

class AngularValidationExtension extends AbstractTypeExtension
{

    public function getExtendedType()
    {
        return 'form';
    }

    public function setDefaultOptions(OptionsResolverInterface $resolver)
    {
        $resolver->setDefined(array('angular_validation'));
    }

    public function buildView(FormView $view, FormInterface $form, array $options)
    {
        if (array_key_exists('angular_validation', $options)) {
            $view->vars['angular_validation'] = $options['angular_validation'];
        }
    }

}

And we need to add the service so Symfony knows it's a form extension.

app/AppBundle/Resources/config/services.yml

    app.form.angular_validation_extension:
        class: AppBundle\Form\Extension\AngularValidationExtension
        tags:
            - { name: form.type_extension, alias: form }    

Once you have these added, you can now use the new extension in your form builder type.

app/AppBundle/Form/Type/YourType.php

        $builder
            ->add('number', 'number', array(
                'angular_validation' => array(
                    'pattern' => '[0-9]{10}', 
                    'message' => 'You must enter a valid phone (digits only)'
                )
            ));

 

On all of your form builder's collection types, you need to set the "prototype_name" manually. This is important! It must be the exact same name as your collection. You must do this for every collection type in your form builder.

app/AppBundle/Form/Type/YourType.php

->add('phones', 'collection', array(
    'type' => new PhoneType(),
    'allow_add'    => true,
    'allow_delete' => true,
    'by_reference' => false,
    'label' => false,
    'prototype_name' => '__phones__'
))

 

Create Twig Filter

We need to create a filter in Twig to escape the prototype for our form collections with addslashes() since the default escape filter appears not to work with AngularJS. This is so our collection HTML goes through AngularJS correctly and that validation works.

app/AppBundle/Twig/AddslashesExtension.php

namespace AppBundle\Twig;

class AddslashesExtension extends \Twig_Extension
{
    public function getFilters()
    {
        return array(
            new \Twig_SimpleFilter('addslashes', array($this, 'add')),
        );
    }

    public function add($html)
    {
        return addslashes($html);
    }

    public function getName()
    {
        return 'addslashes';
    }
}

Then we need to add the service so Symfony uses the Twig filter.

app/AppBundle/Resources/config/services.yml

    app.twig_extension:
        class: AppBundle\Twig\AddslashesExtension
        public: false
        tags:
            - { name: twig.extension }

 

Create Twig Form Block Override

AngularJS needs some things added to the DOM on every input field to validate correctly. The beauty of Twig is that it's very easy to do so using form template overrides. The customization for Symfony  and AngularJS error messages is here. The customization for how the form looks will come soon down the line. This example extends the "bootstrap_3_horizontal_layout.html.twig" but you can have it extend the other templates as well. 

app/AppBundle/Resources/form/angular_form.html.twig

{% extends 'bootstrap_3_horizontal_layout.html.twig' %}

{% block form_start -%}
    {% set attr = attr|merge({'ng-controller': 'SymfonyAngularFormController'}) %}
    {% set attr = attr|merge({'ng-submit': 'submitForm('~form.vars.name~'.$valid, $event)'}) %}
    {% set attr = attr|merge({'novalidate': 'novalidate'}) %}
    {{- parent() -}}
{%- endblock form_start %}

{%- block widget_attributes -%}
    {% set ng_parent_form = _self.getTopFormName(form) %}
    {% set ng_model = ng_parent_form ~ '.' ~ full_name|replace({'[' : '', ']' : ''}) %}
    ng-model="{{ ng_model }}" 
    ng-model-options="{ updateOn: 'mousedown blur' }" 
    ng-init="{{ ng_model }}='{{ form.vars.value }}'" 
    {% if form.vars.angular_validation is defined %}
    pattern="{{ form.vars.angular_validation.pattern }}"
    {% endif %}
    {{- parent() -}}
{%- endblock widget_attributes -%}

{%- block form_errors -%}
    {%- if errors|length > 0 -%}
    <span class="help-block">
        <ul class="list-unstyled">
            {%- for error in errors -%}
                <li>{{ error.message }}</li>
            {%- endfor -%}
        </ul>
    </span>
    {%- else -%}
        {% set ng_parent_form = _self.getTopFormName(form) %}
        {% set ng_model = ng_parent_form ~ '[\'' ~ full_name ~ '\']' %}
        {% set showError = ng_model ~".$invalid && !"~ ng_model ~".$pristine" %} 
        {% set required = ng_model~".$error.required" %}
        {% set email = ng_model~".$error.email" %}
        {% set url = ng_model~".$error.url" %}
        {% set number = ng_model~".$error.number" %}
        {% set minlength = ng_model~".$error.minlength" %}
        {% set maxlength = ng_model~".$error.maxlength" %}
        {% set pattern = ng_model~".$error.pattern" %}
        <span class="help-block" ng-if="{{ showError }}">
            <ul class="list-unstyled">
                <li ng-if="{{ required }}"><span class="glyphicon glyphicon-exclamation-sign"></span> This field is required.</li>
                <li ng-if="{{ email }}"><span class="glyphicon glyphicon-exclamation-sign"></span> You must enter a valid email address.</li>
                <li ng-if="{{ url }}"><span class="glyphicon glyphicon-exclamation-sign"></span> You must enter a fully qualified URL.</li>
                <li ng-if="{{ number }}"><span class="glyphicon glyphicon-exclamation-sign"></span> You must only use numbers.</li>
                <li ng-if="{{ minlength }}"><span class="glyphicon glyphicon-exclamation-sign"></span> Your input does not meet the minimum character requirement.</li>
                <li ng-if="{{ maxlength }}"><span class="glyphicon glyphicon-exclamation-sign"></span> Your input is over the maximum allowed characters.</li>
                {% if form.vars.angular_validation is defined %}
                <li ng-if="{{ pattern }}"><span class="glyphicon glyphicon-exclamation-sign"></span> {{ form.vars.angular_validation.message }}</li>
                {% endif %}
            </ul>
        </span>
    {%- endif -%}
{%- endblock form_errors -%}

{% macro getTopFormName(form) %}
{% spaceless %}
    {% if not form.parent is null %}
        {{ _self.getTopFormName(form.parent) }}
    {% elseif 'attr' in form.vars | keys and 'name' in form.vars.attr | keys %}
        {{ form.vars.attr.name }}
    {% else %}
        {{ form.vars.name }}
    {% endif %}
{% endspaceless %}
{% endmacro %}

 

Then use the form theme on your form render in your view.

{% form_theme form 'AppBundle:form:angular_form.html.twig' %}
{{ form_start(form) }}

 

Using Collections The AngularJS Way

If you're familiar with Symfony, you've probably followed the dynamic collections tutorial where you simply repeat the elements in a list item with javascript using the prototype. This is very similar, however you need to add the form name to many more places and create a seperate template for your actual form rendering. I know it sounds confusing, but I will show you why this is a great benefit over the original tutorial.

Overview:

  1. We can have a collection in our baseform, and a collection in that collection.
  2. We can have one template for both existing items in the collection and dynamically added items.

Each collection get's its own template in twig, this helps for readability and easier customization (no duplicate code between existing form items and prototypes).

Say you only have a form with a "name" text field and one collection named "contacts". 

Form View as "form"

{{ form_row(form.name) }}

{# Collection Contacts #}   
<div class="row" ng-init="collection('{{ form.contacts.vars.id }}')">
    {# Loop Through Existing Collections #}
    {% for contact in form.contacts %}
        <span ng-init="collectionAdd('{{ form.contacts.vars.id }}','{{ form.contacts.vars.name }}','{{ include('AppBundle:prototypes:contacts_prototype.html.twig', { 'form': contacts})|addslashes }}')"></span>
    {% endfor %}
    {# Angular repeat our fields #}
    <div ng-repeat="c in collections.{{ form.contacts.vars.id }}" ng-bind-html="c.html" compile-template></div>
    <div class="clearfix">
        <div class="col-sm-12"> 
            <a class="btn btn-success" title="Add" ng-click="collectionAdd('{{ form.contacts.vars.id }}','{{ form.contacts.vars.name }}','{{ include('AppBundle:prototypes:contacts_prototype.html.twig', { 'form': form.contacts.vars.prototype })|addslashes }}')"><span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add New Contact</a>
        </div>
    </div>
</div>
{# //Collection #}

 

To walk you through the above code:

  • First we need a wrapper with ng-init to tell Angular this is a collection we have. the collection() function needs the collection's ID.
  • Second, the twig loop initializes any existing collection data from doctrine and adds the html into an Angular model. It is important that the tag's ng-repeat contains the collection's Id and the collection's name, along with the prototype that includes the "contact" variable, not to be confused with the "form.contacts" variable! This is putting each individual existing form in Angular.
  • You'll notice a Twig include instead of our {{ form_row(form.contacts) }} that we'd usually have if we followed the Symfony tutorial, this is so we can also use our include template with the prototype and the template will look the same should we want to further customzie our form_row.
  • We then have another div with an ng-repeat. This is for AngularJS to add and remove our form content.
  • After the ng-repeat, we have an add button. This adds a new collection item onto ng-repeat using our prototype HTML. This is why we needed the addslashes filter, so that we could parse the HTML correctly into AngularJS.

For the include "contacts_prototype.html.twig" we just need to create a small template to render out our new form_row()'s. The remove button is required if you want to be able to remove items. The remove button needs the collection's id so we just need to use {{ form.parent.vars.id }}

app/AppBundle/Resources/views/form/prototype/contacts_prototype.html.twig

{{ form.number }}
<a class="btn btn-danger" title="Remove" ng-click="collectionRemove('{{ form.parent.vars.id}}', $index)"><span class="glyphicon glyphicon-remove" aria-hidden="true"></span></a>

 

Here's an example if you want to embed a collection inside a collection:

app/AppBundle/Resources/form/prototypes/contacts_prototype.html.twig

{{ form_row(form.name) }}   

{# Collection Number - remember to change the names everywhere for your new collection #}   
<div class="row" ng-init="collection('{{ form.phones.vars.id }}')">
    {# Loop Through Existing Collections #}
    {% for phone in form.phones %}
        <span ng-init="collectionAdd('{{ form.phones.vars.id }}','{{ form.phones.vars.name }}','{{ include('AppBundle:prototypes:phones_prototype.html.twig', { 'form': phones })|addslashes }}')"></span>
    {% endfor %}
    {# Angular repeat our dynamic fields #}
    <div ng-repeat="c in collections.{{ form.phones.vars.id }}" ng-bind-html="c.html" compile-template></div>
    <div class="clearfix">
        <div class="col-sm-12"> 
            <a class="btn btn-success" title="Add" ng-click="collectionAdd('{{ form.phones.vars.id }}','{{ form.phones.vars.name }}','{{ include('AppBundle:prototypes:phones_prototype.html.twig', { 'form': form.phones.vars.prototype })|addslashes }}')"><span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add New Phone</a>
        </div>
    </div>
</div>
{# //Collection #}

<a class="btn btn-danger" title="Remove" ng-click="collectionRemove('{{ form.parent.vars.id}}', $index, 'Are you sure you want to remove this contact?')"><span class="glyphicon glyphicon-remove" aria-hidden="true"></span></a>

 

Prompt confirmation is built in on the remove button but it is optional. Just leave the 3rd parameter off of the ng-click collecitonRemove to disable the prompt. Add your own custom prompt message if you would like a confirmation before the collection item is removed.

Put It All Together With AngularJS

Now that we have all of our backend code in place, we can go ahead and add our new Angular module:

(function() {
    
    //SYMFONY + ANGULAR FORMS
    app = angular.module('SymfonyAngularForm', [])
    
    //ANGULAR + SYMFONY FORM CONTROLLER
    //handles form submission validation and collections
    .controller('SymfonyAngularFormController', ['$scope', '$sce', function ($scope, $sce) {

        $scope.collections = []; //this holds the new "models" (the collection's HTML)
        $scope.collectionIndexes = []; //this holds the "model" indexes. We can not reuse the same index when we add/delete (angular will be weird with values)

        $scope.collection = function(collectionId) {
            $scope.collections[collectionId] = [];  //adds new collection with index key to keep track of our index
            $scope.collectionIndexes[collectionId] = 1;  //starts off our index at 1
        };
        
        //Adds one item onto the specific collection
        $scope.collectionAdd = function (collectionId, collectionName, html) {
            var regEx = new RegExp('__'+collectionName+'__', 'g'); //want to replace the prototype placeholders with our collection index
            $scope.collections[collectionId].push({
                html: $sce.trustAsHtml(html.replace(regEx, $scope.collectionIndexes[collectionId])),
            }); 
            $scope.collectionIndexes[collectionId]++;
        }
        
        //Removes one item from specific collection, optinal message param if you want a message warning to confirm deletion of the item from the collection
        $scope.collectionRemove = function (collectionId, index, message) {
            if (message !== undefined) {
                if (confirm(message)) {
                    $scope.collections[collectionId].splice(index, 1);
                }
            } else {
                $scope.collections[collectionId].splice(index, 1);
            } 
        }

        $scope.submitForm = function(form, e) {  
            var formErrors = form.$error;
            if (form.$valid != true) {
                for (var validation in formErrors) {
                    for (var error in formErrors[validation]) {
                        formErrors[validation][error].$setDirty();
                    }
                }
                $('html, body').animate({
                      scrollTop: $("form[name='"+form.$name+"']").offset().top -100
                }, 2000);  
                alert('Please review the form and correct any errors. All fields should be "green"');
                e.preventDefault(); 
            }
        };
        
    }])
    //END ANGULAR + SYMFONY FORM CONTROLLER

    //this compiles the HTML so angular can "see" it
    .directive('compileTemplate', function($compile, $parse) {
        return {
            link: function (scope, element, attr) {
                var parsed = $parse(attr.ngBindHtml);
                function getStringValue() { return (parsed(scope) || '').toString(); }

                //Recompile if the template changes
                scope.$watch(getStringValue, function() {
                    $compile(element, null, -9999)(scope);  //The -9999 makes it skip directives so that we do not recompile ourselves
                });
            }         
        }
    })

})();

Once you have that, you can initate it with ng-app. Don't forget!

ng-app="SymfonyAngularForm"

Yay! You're done! Now you have Angular form validation for the entirety of your Symfony forms. Symfony still drives the wheel and you can sit back and relax. Angular takes care of all the pesky inconsistancies you get with different browsers and HTML5 validation.