Skip to content

Single Page Application example using AngularJS, Backbone.js and PHP Slim

Run the app or view demo.

git clone https://github.com/mmukhin/spa-tutorial
cd spa-tutorial
php -S localhost:8080 # built-in web server requires PHP >= 5.4.0

Navigate to: localhost:8080

This is an example Single Page Application (SPA) comparing and contrasting Backbone.js and AndularJS. The API is written using Slim for PHP.

API using PHP Slim

The api runs from api/index.php.

Write models to create, update, retrieve data from storage using Object Oriented Programming.

class TeamModel extends AbstractModel {

    protected $_id;
    protected $_city;
    protected $_name;

    public function fetchOne($id)
    {
        if (array_key_exists($id, $_SESSION['teams'])) {
            return $_SESSION['teams'][$id];
        }
        else {
            return false;
        }
    }

    /* more methods */
}

Write routes to calls models which create, update, retrieve data with Slim routes.

$app->get('/teams/:id', function ($id) use ($app) {
    $TeamModel = new TeamModel();
    $app->response()->body( json_encode($TeamModel->fetchOne($id)) );
});

That’s it for the api.

Client Javascript MVC

Routing

In the browser, the user is able to navigate around the app with the help of routes. When a link is clicked, the router either listens for a navigation event or parses the URL, then executes the logic within.
Consider how the URLs look (each type is available in Backbone.js & AngularJS):

Managing State

SPAs execute from a single page.. ;). When you copy links to pages within your app to send to friends, you intend your friend to see the page rendered the same exact way as you see it (e.g. paginated to page 3, sorted by date, purple). This principle is called managing application state, which refers to the specific configuration of information (how the interface looks) in an application. Mismanaging application state sucks. Keep this in mind, google is your friend.

HTML Templates

Do you really have to update code in each JavaScript file that uses a certain chunk of HTML? No! Use templates!
Do not write HTML within JavaScript code. Write HTML within templates. Underscore.js templates (Backbone.js dependency) and Mustache templates are helpful.

UI Flow

User clicks around the application.
Routes listen for clicks or URL changes.
Routes execute necessary business logic (e.g. calling controllers, views).
Business logic handles models and views.
Models synchronize data with AJAX requests to the API.
Views generate HTML using templates.

API Flow

Web server (e.g. PHP test server, apache, nginx) accepts HTTP requests and calls executes API code.
API routes match incoming request URLs and execute necessary business logic.
Models synchronize data with the database/storage.
API routes respond with a status code, headers and data.

Backbone.js Code Samples

Router (link)

NBAApp.Router = Backbone.Router.extend({
    routes: {
        'about':        'about',
        'league':       'league',
        '*catchAll':    'league'
    },

    /* more methods */
});

Models (link)

NBAApp.Models.Team = Backbone.Model.extend({
    url: function(){
        return apiUrl+'/teams/' + this.get('id');
    },
    defaults: function() {
        return {
            id: null,
            name: '',
            city: ''
        };
    },
    initialize: function() {
    }
});

NBAApp.Collections.Teams = Backbone.Collection.extend({
    url: apiUrl+'/teams/',
    model: NBAApp.Models.Team,
    initialize: function() {
    },
    comparator: function(a) {
        return a.get('name');
    }
});

Views (link)

var LeagueView = Backbone.View.extend({
    el: '#league',
    events: {
        'click #new-team-add': 'onTeamCreate',
        'keyup #backbone-search': 'keywordSearch'
    },
    initialize: function() {

        this.teams = new NBAApp.Collections.Teams;
        this.teams.fetch();

        this.listenTo(this.teams, 'sync', this.onCollectionSync);
        this.listenTo(this.teams, 'destroy', this.onCollectionModelDestroy);

        this.input = $('#new-team-add');
        this.keyword = $('#backbone-search');
    },
    /**
     * Override parent method, render HTML from template
     */
    render: function(optionalCollection) {

        var that = this;
        optionalCollection = optionalCollection || this.teams;

        if (optionalCollection.length > 0) {
            this.clear();
            _.each(optionalCollection.models, function(v) {
                that.onCollectionAdd(v);
            });
        }
        else {
            this.clear();
        }

        this.$el.show();

        this.updateTeamsCount();

        // enable chained calls
        return this;
    },

    /* more methods */
});

Templates (link)

<!-- Template: editing a team -->
<script type="text/template" id="backbone-edit-team">
    <!-- determine whether or not to add the "hide" class -->
    <form>
        <div style="display:inline-block;min-width: 500px;">
            <input type="text" id="edit-team-city" value="<%- city %>" placeholder="Team City" />
            <input type="text" id="edit-team-name" value="<%- name %>" placeholder="Team Name" />
        </div>
        <div style="display:inline-block;min-width: 500px;">
            <!-- click listeners bound to $scope -->
            <input type="submit" id="backbone-team-save" class="button" value="Save" />
            <input type="button" id="backbone-team-cancel" class="button" value="Cancel" />
        </div>
    </form>
</script>

AngularJS Code Samples

Service (API) Module (link)

NBAappModule.factory('teamStorage', function ($http) {

    var apiUrl = 'http://localhost:8080/api';

    return {
        getAll: function () {
            return $http({
                method: 'GET',
                url: apiUrl+'/teams/'
            });
        },
        put: function (team) {
            return $http({
                method: 'PUT',
                url: apiUrl+'/teams/' + team.id,
                data: JSON.stringify(team)
            });
        },
        post: function (team) {
            return $http({
                method: 'POST',
                url: apiUrl+'/teams/',
                data: JSON.stringify(team)
            });
        },
        delete: function (team) {
            return $http({
                method: 'DELETE',
                url: apiUrl+'/teams/' + team.id
            });
        }
    };
});

Controller (link)

NBAappModule.controller('NBAController', function ($scope, teamStorage, filterFilter) {

    // array of teams, used with ng-repeat in HTML
    $scope.teams = [];

    teamStorage.getAll().success(function(data){
        $scope.teams = data;
    });

    // listener for changes in teams array
    $scope.$watch('teams', function (newValue, oldValue) {

        // update binding in HTML
        $scope.totalTeams = $scope.teams.length;
    }, true);

    // brings up editing view
    $scope.editTeam = function (team) {

        // HTML displays team view by checking this property
        team.editing = true;

        // clone the original team to restore it later
        $scope.originalTeam = angular.extend({}, team);
    };

    /* more methods */
});

Templates (link)

<form ng-class="{ hide: ! team.editing }">
    <div style="display:inline-block;min-width: 500px;">
        <input type="text" ng-model="team.city" placeholder="Team City" />
        <input type="text" ng-model="team.name" placeholder="Team Name" />
    </div>
    <div style="display:inline-block;min-width: 500px;">
        <!-- click listeners bound to $scope -->
        <input type="submit" class="button" ng-click="saveEditing(team)" value="Save" />
        <input type="button" class="button" ng-click="cancelEditing(team)" value="Cancel" />
    </div>
</form>

Final Important Topics

CORS

Cross Origin Resource Sharing is a simple concept closely related to JS MVCs. If you foresee your API being routed to a subdomain (e.g. api.website.com), you will encounter CORS.
CORS boils down to:

  • By default, cookies are NOT sent with cross-domain requests. To send cookies, use
    jQuery’s withCredentials: true, while the API response should send header Access-
    Control-Allow-Credentials: true
  • To allow client access from subdomains, you must specify the domains which are allowed (e.g. Access-Control-Allow-Origin: *, Access-Control-Allow-Origin: http://api.website.com)

The API will be required to have OPTIONS methods for all non-GET methods if CORS is used. These are called pre-flight requests, the browser verifies which methods are available on the certain route.

Important: If credentials are allowed (Access-Control-Allow-Credentials: true), Access-Control-Allow-Origin must not use the wildcard “*”. (Reference)

Client-Side Error Handling

How can you know whether the JS MVC is working for your users? One way is to quietly log all errors on the client, and send them to your server or a service like Rollbar for review.

window.onerror = function(msg, url, line) {
    alert("Error caught: " + msg + url + line);
    // @todo for you: ajax request to server goes here
};

This function is triggered if there is an uncaught exception or a compile time error.

Server-Side Error Handling

When building the API, consider standardizing responses that contain errors (e.g. HTTP Status Code 401, 400, 500). Responses with a JSON payload { error: true, message: ‘Reason for error’ }. Your client’s service layer (AJAX) can check for non-200 status codes by looking at response headers, response.error == true, or using jQuery’s error() callback.

If you are using a framework, it’s likely to provide a global exception handler. If you have a bug in a database query and do not surround the query execution with a try{} catch(){} block, a fatal error will occur. This will cause the script to stop executing, and send very little information to the client. To tell the client whether the error was the client’s or the server’s, a global error handler should catch all errors, log the information to a file, and respond with a human readable message:

{ error: true, message: ‘Reason for error’ }

For Fun

Interact with your application through the console. Makes for good practice.

// In developer tools -> console
var m = new NBAApp.Models.Team();
m.set('name','Lakers').set('city','Los Angeles').save();
// Refresh app