building single page application (spa) with symfony2 and angularjs
DESCRIPTION
Forget about classic website where UX is not so important. We are living in time where usability is one of the important thing if you are building some business client oriented web service. How to connect Symfony2 as backend and AngularJS as frontend solution? What are best practices? What are disadvantageous? How to take best from both worlds? These are topics I will cover in my talk with real examples.TRANSCRIPT
Building SPA with Symfony2 and AngularJS
Antonio Perić-Mažar
02.10.2014, ZgPHP Conference 2014
https://joind.in/12007
About me
• Antonio Perić-Mažar, mag. ing. comp.
• CEO @ locastic• Software developer, Symfony2• Sylius Awesome Contributor :)
• www.locastic.com• [email protected]• twitter: @antonioperic
Who we are?
• locastic (www.locastic.com)• Web and mobile development• UI/UX design• Located in Split, Croatia
Symfony2 AngularJS
Usage, language Backend, PHP Frontend, Javascript
Dependency Injection Yes Yes
Templating Twig HTML
Form component Yes Yes
Routing component Yes Yes
MVC Yes Yes
Testable Yes Yes
Services Yes Yes
Events Yes Yes
i18n Yes Yes
Dependency management Yes Yes
etc ... ...Detailed comparison: http://vschart.com/compare/angularjs/vs/symfony
SPA Aka SPI (Single Page interface) desktop apps UX HTML / JS / CSS / etc in single page load fast AJAX and XHR
RESTful wsSimpler than SOAP & WSDL
Resource-oriented (URI)
Principles:
HTTP methods (idempotent & not)
stateless
directory structure-like URIs
XML or JSON (or XHTML)
GET (vs HEAD), POST, PUT (vs PATCH), DELETE, OPTIONS
Building Rest API with SF2
JMSSerializerBundle
(de)serialization
via annotations / YAML / XML / PHP
integration with the Doctrine ORM
handling of other complex cases (e.g. circular references)
Building Rest API with SF2Locastic\Bundle\TodoBundle\Entity\Todo:
# exclusion_policy: ALL exclusion_policy: NONE properties:# description:# expose: true createdAt:# expose: true exclude: true deadline: type: DateTime<'d.m.Y. H:i:s'># expose: true done:# expose: true serialized_name: status
Building Rest API with SF2fos_rest:
disable_csrf_role: ROLE_API
param_fetcher_listener: true
view:
view_response_listener: 'force'
formats:
xml: true
json: true
templating_formats:
html: true
format_listener:
rules:
- { path: ^/, priorities: [ html, json, xml ], fallback_format: ~, prefer_extension: true }
exception:
codes:
'Symfony\Component\Routing\Exception\ResourceNotFoundException': 404
'Doctrine\ORM\OptimisticLockException': HTTP_CONFLICT
messages:
'Symfony\Component\Routing\Exception\ResourceNotFoundException': true
allowed_methods_listener: true
access_denied_listener:
json: true
body_listener: true
Building Rest API with SF2/**
* @ApiDoc( * resource = true, * description = "Get stories from users that you follow (newsfeed)", * section = "Feed", * output={ * "class" = "Locastic\Bundle\FeedBundle\Entity\Story" * }, * statusCodes = { * 200 = "Returned when successful", * 400 = "Returned when bad parameters given" * } * ) * * @Rest\View( * serializerGroups = {"feed"} * ) */public function getFeedAction(){ $this->get('locastic_auth.auth.handler')->validateRequest($this->get('request'));
return $this->getDoctrine()->getRepository('locastic.repository.story')->getStories($this->get('request')->get('me'));}
Templating<!DOCTYPE html>
<html> <head> <meta charset="UTF-8" /> <title>{% block title %}Welcome!{% endblock %}</title> {% block stylesheets %} <!-- Place favicon.ico and apple-touch-icon.png in the root directory -->
{#<link rel="stylesheet" href="{{ asset('css/normalize.css') }}">#} <link rel="stylesheet" href="{{ asset('css/main.css') }}">
<!-- load bootstrap and fontawesome via CDN --> <link rel="stylesheet" href="//netdna.bootstrapcdn.com/bootstrap/3.0.0/css/bootstrap.min.css" /> <link rel="stylesheet" href="//netdna.bootstrapcdn.com/font-awesome/4.0.0/css/font-awesome.css" />
<script src="{{ asset('js/vendor/modernizr-2.6.2.min.js') }}"></script> {% endblock %} <link rel="icon" type="image/x-icon" href="{{ asset('favicon.ico') }}" /> </head> <body> {% block body %}{% endblock %} {% block javascripts %}
<script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.2.16/angular.min.js"></script> <script src="https://code.angularjs.org/1.2.16/angular-route.min.js"></script> <script src="{{ asset('js/main.js') }}"></script>
{% endblock %} </body></html>
Templating
var phpDayDemoApp = angular.module('phpDayDemoApp', [],
function($interpolateProvider) {
$interpolateProvider.startSymbol('[['); $interpolateProvider.endSymbol(']]');});
Now we can use
{% block content %}
[[ message ]] {# rendered by AngularJS #}
{% end block %}
Tweak Twig lexer delimiters? Bad idea.
Templating
Using assetic for minimize
{% javascripts
"js/angular-modules/mod1.js" "#s/angular-modules/mod2.js" "@AngBundle/Resources/public/js/controller/*.js"
output="compiled/js/app.js"
%}
<script type="text/javascript" src="{{ asset_url }}"></script>
{% endjavascripts %}
TemplatingUsing assetic for minimize
Since Angular infers the controller's dependencies from the names of arguments to the controller's constructor function, if you were to minify the JavaScript code for PhoneListCtrl controller, all of its function arguments would be minified as well, and the dependency injector would not be able to identify services correctly.
Use an inline annotation where, instead of just providing the function, you provide an array. This array contains a list of the service names, followed by the function itself.
function PhoneListCtrl($scope, $http) {...}
phonecatApp.controller('phpDayCtrl', ['$scope', '$http', PhoneListCtrl]);
Managing routesClient side:
ngRoute
independent since Angular 1.1.6
hashbang #! & HTML5 mode
<base href="/">
$locationProvider
.html5Mode(true)
.hashPrefix('!');
Also good for SEO!
Managing routeshttp://localhost/todos
http://localhost/#todos
Resolving conflicts
Fallback, managing 404
angular:
path: '/{route}'
defaults: { _controller: LocasticAngularBundle:Default:index}
requirements:
route: ".+"
Managing routes – client side
// module configuration...$routeProvider.when('/todos/show/:id', {
templateUrl : 'todo/show',
controller : 'todoController'
})
// receive paramssfugDemoApp.controller('todoController', function($scope, $http, $routeParams){
$scope.todo = {};
$http
.get('/api/todo/show/' + $routeParams.id)
.success(function(data){
$scope.todo = data['todo'];
});
});
Managing routesThink of using FOSJsRoutingBundle for Frontend route managament
<script src="{{ asset('bundles/fosjsrouting/js/router.js') }}"></script>
<script src="{{ path('fos_js_routing_js', {"callback": "fos.Router.setData"}) }}"></script>
my_route_to_expose_with_defaults:
pattern: /blog/{page}
defaults: { _controller: AcmeBlogBundle:Blog:index, page: 1 }
options:
expose: true
It's as simple as calling:
→ Routing.generate('my_route_to_expose_with_defaults', {'page': 3})
Or if you want to generate absolute URLs:
→ Routing.generate('my_route_to_expose_with_defaults', {'page': 3}, true)
More info: https://github.com/FriendsOfSymfony/FOSJsRoutingBundle
Managing routes – server side
locastic_rest_todo_getall: pattern: /api/get-all defaults: _controller: LocasticRestBundle:Todo:getAll
locastic_rest_todo_create: pattern: /api/create defaults: _controller: LocasticRestBundle:Todo:create
locastic_rest_todo_show: pattern: /api/show/{id} defaults: _controller: LocasticRestBundle:Todo:show
Translations
AngularJS has its own translation system I18N/L10N . But it might be interesting to monitor and centralize translations from your backend Symfony.
JMSTranslationBundle
FormssfugDemoApp.directive('ngToDoForm', function() {
return { restrict: 'E', template: '<div class="todoForm">Form will be!</div>' }});
'A' - <span ng-sparkline></span>
'E' - <ng-sparkline></ng-sparkline>
'C' - <span class="ng-sparkline"></span>
'M' - <!-- directive: ng-sparkline →
Usage of directive in HTML:
<ng-to-do-form></ng-to-do-form>
FormssfugDemoApp.directive('ngToDoForm', function() {
return { restrict: 'E', templateUrl: '/api/form/show.html' }});
FormssfugDemoApp.directive('ngToDoForm', function() {
return { restrict: 'E', templateUrl: '/api/form/show.html' }});
locastic_show_form: pattern: /form/show.html defaults: _controller: LocasticWebBundle:Default:renderForm
public function renderFormAction(){ $form = $this->createForm(new TodoType());
return $this->render('LocasticWebBundle::render_form.html.twig', array( 'form' => $form->createView() ));}
Forms
Template behind directive
<form class="form-inline" role="form" style="margin-bottom: 30px;"> Create new todo: <div class="form-group"> {{ form_label(form.description) }} {{ form_widget(form.description, {'attr': {'ng-model': 'newTodo.description', 'placeholder': 'description', 'class': 'form-control'}}) }} </div> <div class="form-group"> <label class="sr-only" for="deadline">Deadline</label> <input type="text" class="form-control" id="deadline" placeholder="deadline (angular-ui)" ng-model="newTodo.deadline"> </div> <input type="button" class="btn btn-default" ng-click="addNew()" value="add"/></form>
Submitting forms? When the AngularJS $http service POSTs data the header application/x-www-form-
urlencoded is never set (unlike jQuery’s $.ajax()). Also, the $http data is not serialized when sent. Both of these facts mean that the $_POST variable is never set properly by php. Without the $_POST variable Symfony’s built in form handling cannot be used.
The fix is actually pretty simple: angular needs to forced into setting a header the data needs to be serialized and the data needs to be normalized into a multidimensional array.
Submitting forms? var postData = {
formtype_name: {
id: some_id,
name: some_name
}
};
$http({
method: "POST",
url: url,
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
data: $.param(postData)
});
Same-origin policyFor security reasons, web browsers prevent JavaScript to Ajax requests (XMLHttpRequest) to other areas
( Same-origin policy ).
An example of exception thrown by the browser:
XMLHttpRequest cannot load http://api.mondomaine.com/v1/maressource.json.
Invalid HTTP status code 405
Using JSOP (Json with Padding) – easy with FOSRestApi Configure server (simple and stupid)
<VirtualHost *:80> ServerName mon-appli-angular.com DocumentRoot /var/www/some-ng-app/ Alias /api /var/www/some-ng-app/ <Directory xxxx> </Directory> </VirtualHost> </VirtualHost>
Use CorsCORS (Cross-origin resource sharing) is an elegant and standardized response to allow Cross-domain requests.Be careful though, the CORS mechanism is not supported by all browsers (guess which ) …One can easily implement the CORS mechanism in Symfony2 with the bundle NelmioCORSBundle . This bundle will add seamlessly headers "CORS" in outgoing HTTP requests from our Symfony application.
Testing
Symfony and AngularJS are designed to test. So write test
Behat
PHPUnit
PHPSpec
Jasmine
…
Or whatever you want just write tests
SummaryThe cleanest way is to separate backend and frontend. But there is some advantages to use both together.
Twig + HTML works well.
Assetic Bundle is very useful to minify bunches of Javascript files used by AngularJs
Translation in the template. the data in the API payload does not need translation in most cases. Using Symfony I18N support for the template makes perfect sense.
Loading of Option lists. Say you have a country list with 200+ options. You can build an API to populate a dynamic dropdown in angularjs, but as these options are static, you can simply build that list in twig. Forms in Symfony are pretty cool
Take advantage of the authentication feature of Symfony. You can used the same authenticated session to the API for the application, too. No need to use Oauth.
And remember
Keep controllers small and stupid, master Dependency injection, delegate to services and events.