Practical Symfony
Practical Symfony
bernard peh
Buy on Leanpub

Table of Contents

Preface

This is a book documenting the creation of SongBird, a simple CMS created using Symfony Full Stack which itself consists of many Symfony Components. Although the resources in symfony.com is great, they are often hard to digest and you need to piece all the code snippets up to build something useful. I wanted to write an application that covers different aspects of Symfony as much as possible. I believe this approach will be helpful for people who are new to Symfony. This idea was conceptuaised in early 2015 but the implementation turned out to be much longer that I thought - I had to upgrade Symfony from 2 to 3 and dropped SonataAdmin in favor of EasyAdmin when I was 90% done.

The project was finally completed in Aug 2016 after much persistence and has gone through several revisions since.

The objective of this project is to:

  • Illustrate the power of rapid development with Symfony.
  • Reduce the learning curve by sharing step-by-step guide to create a working application. This process should be helpful to anyone who wants to dive into Symfony.
  • Kickstart simple Symfony projects with this application.
  • Share the fun of programming.

It is important to note that the rationale behind SongBird CMS is to have broad coverage. Therefore, I try not to repeat the same techniques in every chapter. In reality, you might find some techniques work really well and will want to keep reusing them. On the other hand, you might find certain implementations overkill. This is all for learning purpose.

I hope you have fun creating your own version of SongBird by following the exercises in this book, choosing your best arsenal for your future adventures.

Cheers,

Bernard Peh

Introduction

Building an application is like building a Pyramid. You create each piece of functionality bit by bit. You test the functionality and make sure its stable before building the next piece. This cycle continues until you reach the peak - completion.

Choosing the best framework for RAD (Rapid Application Development) has been a topic debated to death. Today, there is no longer such a thing as “The Best Framework” because all modern day frameworks follow the best practice. However, there is such a thing called “The Best Practice”. In fact, you can see similar development methodologies being used across all frameworks. So knowing one framework well means you can jump between other frameworks easily. Just as human evolves, different frameworks learn from each other and adapt very fast to new and better technologies.

At the time of writing, NodeJS continues to innovate with PHP closing in fast behind. PHP is the old veteran when comes to web development with the most frameworks in the market. The 2 frameworks that stood out from the pack were Laravel and Symfony. If you are looking to learn a new framework, I highly recommend Symfony because it is one of the more stable modern framework out there. Symfony components have been widely used by many popular projects including Composer, Behat, Codeception, Drupal and Laravel (just to name a few).

Learning Symfony is never an easy task. Many people follow tutorials in google, read up all the documentation in Symfony website and still find it challenging to create a simple application. Why? Because there is too much theory and not enough real world practical examples. Worst still, you can get entangled in technical jargons and advance customisations easily. The fact that Symfony is an extremely flexible framework makes it even harder to master because there are so many ways to achieve the same goal. If you are new to MVC (Model-View-Controller) and RAD, you will find that Symfony has a steep learning curve.

This book aims to lower the learning curve by providing a step by step hands-on approach to guide developers who are new to Symfony to build a simple CMS using good industry practice. Let us call the CMS “SongBird”. Hopefully after following all the chapters, your eyes will be opened to RAD and the unlimited possibilities with Symfony.

Audience

This book is targeted at developers who are new to Symfony. If you are already a seasoned PHP Developer, I hope you would pick up some tips here and there.

Why Re-invent the Wheel?

At the time of writing, there are already many CMS and a few popular Symfony ones out there. Symfony has the CMF project. Why built a new one?

SongBird is really a tutorial project and not trying to compete in the CMS space.

Is SongBird Reusable?

Definitely. SongBird is not just a tutorial CMS, you can use it as a vanilla framework to kickstart other Symfony projects. It will be a hugh time saver because all common features have been build and configured already. Since you are the one who creates the software, you will have better idea of how the software works and know where to customise things should the need arises.

You can also think of SongBird as the foundation for the CMF project. Once you are comfortable with the basics of building a CMS, you are ready for more complex development.

Chapters Overview

Chapter 1: Survival Skills

A quick introduction to the skills required to learn Symfony.

Chapter 2: What is SongBird

Introduction to what Songbird is and isn’t.

Chapter 3: Creating the Dev Environment

Installing Songbird using docker. Docker is fantastic but its a shame that mac users need a work around at the time of writing.

Chapter 4: The Testing Framework Part 1 (Optional)

Introduces and Installing Codeception for Behavioural Testing.

Chapter 5: The Testing Framework Part 2 (Optional)

Writing a sample BDD acceptance test.

Chapter 6: The User Management System Part 1

Introduces and Installing FOSUserBundle for User Management.

Chapter 7: The User Management System Part 2

Generating user CRUD using the command line and a bit of doctrine appetizer.

Chapter 8: Doctrine Fixtures and Migrations

Installing and running Doctrine Fixtures and Migrations. It is important to have a consistent way of creating test data and migrating schemas.

Chapter 9: The Admin Panel Part 1

Installing EasyAdminBundle and integrating it with FOSUserBundle.

Chapter 10: BDD With Codeception (Optional)

Writing BDD acceptance tests for user management business rules.

Chapter 11: Customising the Login Process

Customising Twig templates for the login and request password pages.

Chapter 12: The Admin Panel Part 2

Tweaking EasyAdmin UI.

Chapter 13: Internalisation

Getting Songbird to support french as well.

Chapter 14: Uploading Files

Installing VichUploaderBundle and integrating it with EasyAdminBundle.

Chapter 15: Logging User Activities

Creating a simple bundle to log user activities.

Chapter 16: Improving Performance and Troubleshooting

Installing blackfire and improving Symfony performance. Introduces Gulp to manage all frontend assets.

Chapter 17: The Page Manager Part 1

Creating a custom bundle called NestablePageBundle to manage pages. Introduces PHPUnit to write functional tests.

Chapter 18: Making Your Bundle Reusable

Refactoring NestablePageBundle and making it as a separate installable component.

Chapter 19: The Page Manager Part 2

Installing CKEditor to the CMS and creating a custom locale selector.

Chapter 20: The Front View

Creating the frontend controller and view.

Chapter 21: Dependency Injection Revisited

Using Compiler Pass to add user roles to EasyAdminBundle.

Chapter Final

Congratulations. It’s time to start build something yourself using Symfony.

Conventions Used in This Book

Each git branch is a chapter. Obviously, chapter_6 branch means it is Chapter 6. Otherwise stated, all path references assumes ~/songbird as the root folder. Always execute commands from the root folder.

To executing commands, You will see a “->” before the command. For example

 1 -> git status
 2 
 3 On branch chapter_6
 4 Changes not staged for commit:
 5   (use "git add <file>..." to update what will be committed)
 6   (use "git checkout -- <file>..." to discard changes in working directory)
 7 
 8 	modified:   symfony/app/AppKernel.php
 9 	modified:   symfony/app/config/routing.yml
10 
11 Untracked files:
12   (use "git add <file>..." to include in what will be committed)
13 
14 	symfony/src/myfolder/
15 
16 no changes added to commit (use "git add" and/or "git commit -a")

This means that in the command line terminal, go to the ~/songbird folder and type in “git status”.

Likewise, a code snippet like this

1 # symfony/app/config/routing.yml
2 ...
3 Songbird_user:
4     resource: "@SongbirdUserBundle/Controller/"
5     type:     annotation
6     prefix:   /

means update or insert this snippet in ~/songbird/symfony/app/config/routing.yml

or it could mean a comment for you to action like

1 # you should commit your changes now.
2 -> git commit -m"update file changes"

All symfony commands runs under the symfony dir, ie

1 # in the symfony dir
2 -> bin/console debug:router

Learning Symfony

If you are new to RAD and like to learn Symfony, I recommend you to go through the chapters in sequential order. Every time you are on a new chapter, create a new branch based on the previous chapter and try to add or update the code as suggested in the chapter. For example, you have just finished chapter 4 and going into chapter 5.

Commit all your changes in chapter 4 first.

1 -> git commit -m"This is chapter 4 commit comments"

Then checkout chapter 5.

1 # this command will create a new mychapter_5 branch based off your current branch
2 -> git checkout -b mychapter_5

We use mychapter_x to differentiate between your work and my work. To look at all the branches available:

 1 -> git branch -a
 2 
 3   mychapter_4
 4 * mychapter_5
 5   ...
 6   master
 7   remotes/origin/HEAD -> origin/master
 8   remotes/origin/chapter_4
 9   remotes/origin/chapter_5
10   ...

If you are being lazy and want to use my chapter 4 instead to start chapter 5,

1 -> git checkout -b mychapter_5 origin/chapter_4

If you are already getting confused, here are some good git resource to read.

Jumping between Chapters

I have organised the repository such that every chapter will have its own corresponding branch in the code. Feel free to jump between the different chapters and test out the code. However, remember to stash or commit your changes before switching to a new branch. Also remember to clear your cache if things are broken.

Chapters that talk about Codeception Testing Framework are optional. Feel free to skip them if you already know testing.

To clear the cache fully,

1 rm -rf symfony/var/cache/*

Regenerating Bootstrap Cache

If you are getting errors on bootstrap.php.cache, you can regenerate it easily.

1 -> symfony/vendor/sensio/distribution-bundle/Resources/bin/build_bootstrap.php

Composer Memory Errors

Refer to composer troubleshooting guide if you have problems using composer.

A common issue is when you get allowed memory exhausted error. A quick workaround is

1 -> php -d memory_limit=-1 path_to_composer update

Reinstalling Symfony

Some directories are needed by Symfony but they are not version controlled (eg. the bin directory). In case they have been deleted accidentally, you can reinstall the packages. The re-installation process will not mess up with your existing code. That’s the beauty of being modular.

1 rm -rf vendor
2 composer update

Installing the Demo (Optional)

If you are already getting impatient and wants to see a demo of the completed project, you can checkout the final chapter. Make sure you have docker and docker-compose installed.

 1 # If you are new to web development, you might be unfamiliar with some of the co\
 2 mmands here. Don't worry as they will be explained as you follow through the cha\
 3 pters sequentially.
 4 
 5 -> git clone https://github.com/bernardpeh/songbird
 6 -> cd songbird
 7 -> git checkout chapter_final
 8 
 9 # update SYMFONY_APP_PATH parameters in the .env file and leave the rest as defa\
10 ults
11 -> cp .env.dist .env
12 
13 # we need to create new dir when mounting docker (if case if using nfs)
14 -> mkdir -p .data/db
15 -> mkdir -p logs/{symfony,nginx}
16 -> cd symfony
17 
18 # See chapter 3 to improve mac performance before continuing (if you are interes\
19 ted)
20 -> docker-compose up --build -d
21 
22 # To confirm all your containers are running
23 -> docker-compose ps
24 
25 # add ip to your host file (assuming you are in unix env)
26 -> sudo echo "127.0.0.1 songbird.app" >> /etc/hosts
27 
28 # create the uploads dir
29 mkdir -p web/uploads/{profiles,featured_images}
30 
31 # install symfony libraries - ignore the db errors when running composer install
32 -> ./scripts/composer install
33 
34 # install db and fixtures 
35 -> ./scripts/resetapp
36 
37 # install js libraries
38 -> bower install
39 -> npm install
40 
41 # install assets
42 -> gulp

Docker could be slower for mac users. Chapter 3 provides a workaround.

Now go to http://songbird.app:8000 and you should see the homepage.

You can log into the backend by going to http://songbird.app:8000/admin using

1 user: admin
2 password: admin

To run full BDD test on the site

1 # Optional Step to check site is fully functional
2 -> ./scripts/runtest

References

Chapter 1: Survival Skills

Without a doubt, the 2 biggest Symfony resources on the web at the moment are “The Book” and “The Cookbook”, both can be downloaded from Symfony Documentation Page. Cudos to Fabien and the team behind the books, making Symfony one of the best documented frameworks out there. Having said that, the content in these 2 books are hard to digest and almost impossible to follow unless you have good foundation in Object Oriented Programming. There are a lot to go through. Even if you are have the skills, you will need enough determination to read them. Even if you finish reading them, you still need to have enough practical experience to digest the theory.

I hope there is really a simple formula to become a Symfony ninja overnight…

The Tools You Need

You will need to equip yourself before diving in. Ideally, you have

  • A good computer. I recommend a modern day Mac not more than 4 years old with at least 100G of free space to setup development environment. Mac is fast becoming the new standard for coding. Linux is fine. If you insist in windows, make sure you have command line - cygwin is a good option.
  • Good foundation in programming. Experience with Object Oriented Programming and relational databases is recommended.
  • Understand Dependency Injection (DI). Fabien wrote a good article about DI. DI is the heartbeat of Symfony and most modern day framework.
  • Good source control knowledge, especially Git and Git Flow.
  • Basic HTML, CSS and Javascript knowledge.
  • Basic Stylesheet Pre-processor language like LESS or SASS.
  • Basic Linux command line knowledge.
  • A good IDE. There are lots of them out there. Sublime Text is OK but PHP Storm is way better for serious Symfony development.

I hope the list doesn’t scare you to get started.

Using the Command Line

I suggest you to get comfortable with the command line. Many modern day frameworks use command line to automate tasks. In this book, I’ll be using a lot of command line but I suggest you not to memorise them. Always type “app/console” to see the options and then narrow in from there.

For example, your app/console might look like this (doesn’t matter if it doesn’t at this point):

  1 -> bin/console
  2 
  3 ...
  4 
  5 Available commands:
  6   help                                 Displays help for a command
  7   list                                 Lists commands
  8  assetic
  9   assetic:dump                         Dumps all assets to the filesystem
 10   assetic:watch                        Dumps assets to the filesystem as their s\
 11 ource files are modified
 12  assets
 13   assets:install                       Installs bundles web assets under a publi\
 14 c web directory
 15  cache
 16   cache:clear                          Clears the cache
 17   cache:warmup                         Warms up an empty cache
 18  config
 19   config:debug                         Dumps the current configuration for an ex\
 20 tension
 21   config:dump-reference                Dumps the default configuration for an ex\
 22 tension
 23  container
 24   container:debug                      Displays current services for an applicat\
 25 ion
 26  debug
 27   debug:config                         Dumps the current configuration for an ex\
 28 tension
 29   debug:container                      Displays current services for an applicat\
 30 ion
 31   debug:event-dispatcher               Displays configured listeners for an appl\
 32 ication
 33   debug:router                         Displays current routes for an application
 34   debug:swiftmailer                    Displays current mailers for an applicati\
 35 on
 36   debug:translation                    Displays translation messages information
 37   debug:twig                           Shows a list of twig functions, filters, \
 38 globals and tests
 39  doctrine
 40   doctrine:cache:clear-metadata        Clears all metadata cache for an entity m\
 41 anager
 42   doctrine:cache:clear-query           Clears all query cache for an entity mana\
 43 ger
 44   doctrine:cache:clear-result          Clears result cache for an entity manager
 45   doctrine:database:create             Creates the configured database
 46   doctrine:database:drop               Drops the configured database
 47   doctrine:ensure-production-settings  Verify that Doctrine is properly configur\
 48 ed for a production environment.
 49   doctrine:generate:crud               Generates a CRUD based on a Doctrine enti\
 50 ty
 51   doctrine:generate:entities           Generates entity classes and method stubs\
 52  from your mapping information
 53   doctrine:generate:entity             Generates a new Doctrine entity inside a \
 54 bundle
 55   doctrine:generate:form               Generates a form type class based on a Do\
 56 ctrine entity
 57   doctrine:mapping:convert             Convert mapping information between suppo\
 58 rted formats.
 59   doctrine:mapping:import              Imports mapping information from an exist\
 60 ing database
 61   doctrine:mapping:info
 62   doctrine:query:dql                   Executes arbitrary DQL directly from the \
 63 command line.
 64   doctrine:query:sql                   Executes arbitrary SQL directly from the \
 65 command line.
 66   doctrine:schema:create               Executes (or dumps) the SQL needed to gen\
 67 erate the database schema
 68   doctrine:schema:drop                 Executes (or dumps) the SQL needed to dro\
 69 p the current database schema
 70   doctrine:schema:update               Executes (or dumps) the SQL needed to upd\
 71 ate the database schema to match the current mapping metadata.
 72   doctrine:schema:validate             Validate the mapping files.
 73  fos
 74   fos:user:activate                    Activate a user
 75   fos:user:change-password             Change the password of a user.
 76   fos:user:create                      Create a user.
 77   fos:user:deactivate                  Deactivate a user
 78   fos:user:demote                      Demote a user by removing a role
 79   fos:user:promote                     Promotes a user by adding a role
 80  generate
 81   generate:bundle                      Generates a bundle
 82   generate:controller                  Generates a controller
 83   generate:doctrine:crud               Generates a CRUD based on a Doctrine enti\
 84 ty
 85   generate:doctrine:entities           Generates entity classes and method stubs\
 86  from your mapping information
 87   generate:doctrine:entity             Generates a new Doctrine entity inside a \
 88 bundle
 89   generate:doctrine:form               Generates a form type class based on a Do\
 90 ctrine entity
 91  init
 92   init:acl                             Mounts ACL tables in the database
 93  lint
 94   lint:twig                            Lints a template and outputs encountered \
 95 errors
 96   lint:yaml                            Lints a file and outputs encountered erro\
 97 rs
 98  orm
 99   orm:convert:mapping                  Convert mapping information between suppo\
100 rted formats.
101  router
102   router:debug                         Displays current routes for an application
103   router:dump-apache                   [DEPRECATED] Dumps all routes as Apache r\
104 ewrite rules
105   router:match                         Helps debug routes by simulating a path i\
106 nfo match
107  security
108   security:check                       Checks security issues in your project de\
109 pendencies
110   security:encode-password             Encodes a password.
111  server
112   server:run                           Runs PHP built-in web server
113   server:start                         Starts PHP built-in web server in the bac\
114 kground
115   server:status                        Outputs the status of the built-in web se\
116 rver for the given address
117   server:stop                          Stops PHP's built-in web server that was \
118 started with the server:start command
119  swiftmailer
120   swiftmailer:debug                    Displays current mailers for an applicati\
121 on
122   swiftmailer:email:send               Send simple email message
123   swiftmailer:spool:send               Sends emails from the spool
124  translation
125   translation:debug                    Displays translation messages information
126   translation:update                   Updates the translation file
127  twig
128   twig:debug                           Shows a list of twig functions, filters, \
129 globals and tests
130   twig:lint                            Lints a template and outputs encountered \
131 errors
132  yaml
133   yaml:lint                            Lints a file and outputs encountered erro\
134 rs

Wow, that is a lot but don’t worry, you will get used to the important ones after finishing the book.

Selling Your Soul to the Demon

Many people use web frameworks to create internet applications nowadays. A framework speeds up web development by giving you automation tools to create commonly used features like user management system, forms, pages, menus…etc. This means that you can create these features easily without knowing how they work. It is like buying a car without knowing how the car works. This is all good until if you want to customise the inner components or repair it. You could get someone to customise the car (hire a developer) or DIY.

If you are a developer, there is value in learning how to built a CMS with Symfony. While building the CMS, you learn how to configure and customise all the bundles to make them work together. As the builder, you will know where to start troubleshooting when things go wrong.

Let’s get the ball rolling…

Summary

This is a short chapter. We discussed the basic skills required to learn a modern day framework like Symfony. You were mentally prepared and warned about the pros and cons of using a framework.

References

Chapter 2: What is SongBird

In a nutshell, SongBird is a bare bone CMS (Content Management System) consisting the following features:

  • Admin Panel and Dashboard - A password protected administration area for administrators and users.
  • User Management System - For administrators to manage users of the system.
  • i18n Capability - Multi-lingual. No CMS is complete without this.
  • Page Management System - For managing the front-end menu, slug and content of the site.
  • User Logging Sytem - For logging user activities in the backend.
  • Frontend - The portal where the public interacts with the site. No login required.

We will attempt to built the CMS using some popular modules available to cut down the development time. This is the best approach. However, that also means that we lose the fun of building some cool bundles ourselves. In view of that, we will attempt to build the Page management bundle and frontend ourselves.

So What is the Plan?

In this chapter we are going to define the scope of the software. People spend weeks to write a proper functional specification for a software like this. Functional Specification defines the scope of the project, provides an estimate of the amount of man hours required and duration to complete the job, gives people an idea of what the software is, what it can or can’t do. It is also important to use that as a reference when writing test cases as well.

Writing good functional specs is the most important part of the Software Development Life Cycle. In our case, we shall cut down the words and show only relevant information in developing SongBird.

Use Case Diagram

This is a high level overview of the roles and features of SongBird.

Database Diagram

The entity relationships in a nutshell. In the real world, the relationships won’t be that simple. You should see more one-to-many and many-to-many relationships.

User Journey

This is how I visualise a user would interact with the website. Hopefully, it gives you confidence of what we are about to build.

a) The frontend homepage:

b) The frontend subpage:

c) The login page:

d) Backend dashboard:

d) Backend listing page:

e) Backend record edit page:

We haven’t started coding but you already have a realistic view of the final product.

Sitemap

We are going to start with a few pages only, keeping the navigation simple.

User Stories

A user story defines the functionality that the user wants to have in plain english. We don’t want to drill down to specifics at this stage. The specifics should be in the user scenarios. We make use of “As a”, “I want/don’t want to” and “So that” to help define good user stories.

As an example:

“As a developer, I want to create a simple CMS, so that I can use it as a vanilla CMS for more complex projects”.

We will define the user stories for each chapter as we go along.

User Scenarios

User Scenarios break the user story down into further possible outcomes. I like to think of them as pseudocode. We make use of “Given”, “When” and “Then” to define user scenarios. BDD tests are written based on these scenarios. Based on the example above, Possible scenarios are:

“Given the homepage, When I land on the homepage, Then I should see a big welcome text.”

“Given the about us page, When I navigate to the about us page from the menu, Then I should see my name”

We will define the user scenarios based on the user stories for each chapter as we go along.

Summary

In this chapter, we tried to define what SongBird is and isn’t. We defined the requirements and provided some use cases/diagrams to help define the end product. In real life, requirement docs could be a lot longer. Having well defined requirements is paramount in building robust software.

References

Chapter 3: Creating the Dev Environment

So that we speak the same language throughout the book, we need a dev (development) environment that it is consistent in everyone’s host. We will use docker for this purpose.

The idea is to do actual coding in your host (main operating system) and let docker other services like the web server, MYSQL … etc. Note that 99% of the time, you don’t need to touch the docker instances except to make sure that they are all up and running.

Installation

  • Fork songbird from github
  • Clone it
1 -> cd ~
2 -> git clone git@github.com:your_username/songbird.git
3 -> cd songbird
4 -> git checkout chapter_3
 1 # update SYMFONY_APP_PATH parameters in the .env file and leave the rest as defa\
 2 ults
 3 -> cp .env.dist .env
 4 
 5 # we need to create new dir when mounting docker (if case if using nfs)
 6 -> mkdir -p .data/db
 7 -> mkdir -p logs/{symfony,nginx}
 8 
 9 # update parameters in the .env file if you want, then run
10 -> docker-compose up --build -d
11 
12 # to confirm all the containers are fired up correctly
13 -> docker-compose ps
14       Name                    Command              State               Ports    \
15          
16 --------------------------------------------------------------------------------\
17 --------
18 songbird_db_1      docker-entrypoint.sh mysqld     Up      0.0.0.0:8006->3306/tc\
19 p                      
20 songbird_nginx_1   nginx                           Up      443/tcp, 0.0.0.0:8000\
21 ->80/tcp 
22 songbird_php_1     docker-php-entrypoint php-fpm   Up      0.0.0.0:9000->9000/tcp
23 ...
  • Add songbird.app to your host file. # for unix systems -> sudo echo "127.0.0.1 songbird.app" >> /etc/hosts
  • We have mapped port 8000 to our nginx web server, open up browser and go to http://songbird.app:8000. If you see an installation successful page, you are on the right track.
  • Let us configure the dev url to allow connection from the parent host
 1 # in symfony/web/app_dev.php
 2 
 3 // comment off this ip restriction
 4 
 5 // if (isset($_SERVER['HTTP_CLIENT_IP'])
 6 //     || isset($_SERVER['HTTP_X_FORWARDED_FOR'])
 7 //     || !(in_array(@$_SERVER['REMOTE_ADDR'], ['127.0.0.1', '::1']) || php_sapi\
 8 _name() === 'cli-server')
 9 // ) {
10 //     header('HTTP/1.0 403 Forbidden');
11 //     exit('You are not allowed to access this file. Check '.basename(__FILE__)\
12 .' for more information.');
13 // }
  • Now try this url http://songbird.app:8000/app_dev.php and you should see the same successful page but with a little icon/toolbar at the bottom of the page. That’s right, you are now in dev mode. Why the “app_dev.php”? That is like the default page for the dev environment, something unique to Symfony which we will always be using during development.
  • To check that everything is working, let us look at the logs
 1 -> tail -f logs/{nginx/*,symfony/*}
 2 
 3 ==> logs/nginx/error.log <==
 4 
 5 ==> logs/nginx/symfony_access.log <==
 6 172.18.0.1 - - [19/Jan/2017:00:59:00 +0000] "GET / HTTP/1.1" 200 1947 "-" "Mozil\
 7 la/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko\
 8 ) Chrome/55.0.2883.95 Safari/537.36"
 9 172.18.0.1 - - [19/Jan/2017:00:59:00 +0000] "GET /favicon.ico HTTP/1.1" 200 6518\
10  "http://songbird.app:8000/" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) Ap\
11 pleWebKit/537.36 (KHTML, like Gecko) Chrome/55.0.2883.95 Safari/537.36"
12 172.18.0.1 - - [19/Jan/2017:01:03:18 +0000] "GET /app_dev.php HTTP/1.1" 403 101 \
13 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, \
14 like Gecko) Chrome/55.0.2883.95 Safari/537.36"
15 172.18.0.1 - - [19/Jan/2017:01:04:20 +0000] "GET /app_dev.php HTTP/1.1" 403 101 \
16 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, \
17 like Gecko) Chrome/55.0.2883.95 Safari/537.36"
18 172.18.0.1 - - [19/Jan/2017:01:12:02 +0000] "GET /app_dev.php HTTP/1.1" 200 8132\
19  "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML,\
20  like Gecko) Chrome/55.0.2883.95 Safari/537.36"
21 172.18.0.1 - - [19/Jan/2017:01:12:14 +0000] "GET /app_dev.php/_wdt/2de5e5 HTTP/1\
22 .1" 200 6466 "http://songbird.app:8000/app_dev.php" "Mozilla/5.0 (Macintosh; Int\
23 el Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/55.0.2883.95 \
24 Safari/537.36"
25 172.18.0.1 - - [19/Jan/2017:01:17:49 +0000] "GET /app_dev.php HTTP/1.1" 200 8133\
26  "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML,\
27  like Gecko) Chrome/55.0.2883.95 Safari/537.36"
28 172.18.0.1 - - [19/Jan/2017:01:17:49 +0000] "GET /favicon.ico HTTP/1.1" 200 6518\
29  "http://songbird.app:8000/app_dev.php" "Mozilla/5.0 (Macintosh; Intel Mac OS X \
30 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/55.0.2883.95 Safari/537.3\
31 6"
32 172.18.0.1 - - [19/Jan/2017:01:17:52 +0000] "GET /app_dev.php/_wdt/85ae68 HTTP/1\
33 .1" 200 6488 "http://songbird.app:8000/app_dev.php" "Mozilla/5.0 (Macintosh; Int\
34 el Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/55.0.2883.95 \
35 Safari/537.36"
36 
37 ==> logs/nginx/symfony_error.log <==
38 
39 ==> logs/symfony/dev.log <==
40 [2017-01-19 01:17:45] request.INFO: Matched route "homepage". {"route":"homepage\
41 ","route_parameters":{"_controller":"AppBundle\\Controller\\DefaultController::i\
42 ndexAction","_route":"homepage"},"request_uri":"http://songbird.app:8000/app_dev\
43 .php/","method":"GET"} []
44 [2017-01-19 01:17:46] security.INFO: Populated the TokenStorage with an anonymou\
45 s Token. [] []
46 [2017-01-19 01:17:51] request.INFO: Matched route "_wdt". {"route":"_wdt","route\
47 _parameters":{"_controller":"web_profiler.controller.profiler:toolbarAction","to\
48 ken":"85ae68","_route":"_wdt"},"request_uri":"http://songbird.app:8000/app_dev.p\
49 hp/_wdt/85ae68","method":"GET"} []

Good, nginx and symfony is logging stuff.

Every time your machine restarts, remember to start docker, then run docker-compose up -d in the songbird folder to start the dev environment.

Finally, let us ignore .env in .gitignore

1 # .gitignore
2 
3 /.vagrant/
4 .idea/
5 logs/
6 ...
7 /.env

Mac Users (Optional)

Docker is such an amazing tool and I think it will only get more popular. However at the time of writing, mac operating system suffer performance issues due to osxfs. We can improve the disk access speed by using nfs instead. You can google about this and read about the technical details.

To mount via nfs, click on the docker icon on the top of your desktop -> Preferences -> File Sharing (remove all mounted dirs except /tmp) -> Restart docker.

We can then export the whole /Users dir

1 -> cd ~
2 -> git clone https://github.com/IFSight/d4m-nfs
3 -> cd d4m-nfs
4 -> echo "/Users:/Users" > etc/d4m-nfs-mounts.txt
5 -> sudo ./d4m-nfs.sh
6 # restart docker containers
7 -> cd ~/songbird
8 -> docker-compose down
9 -> docker-compose up -d

Summary

In this chapter, we setup the development environment using docker. We have installed Symfony and configured the host to access SongBird from the host machine.

Remember to commit all your changes before moving on.

Exercises (Optional)

  • Try running Symfony’s build-in webserver. What command would you use? What are the pros and cons of using the build-in webserver?
  • Delete the symfony dir. Reinstall Symfony following the Symfony Installation instructions.
  • How many ways are there to install Symfony? What are the pros and cons of each?

References

Chapter 4: The Testing Framework Part 1 (Optional)

This chapter talks about Codeception. Feel free to skip it if you already have a testing framework in place.

No application is complete without going through a rigorous testing process. Software Testing is a big topic by itself.

Today, many developers know TDD and BDD. Test First Development ensures that your software is reliable but requires a lot of patience and extra work to implement it correctly. Think of it like a quality control process. The more checks you have, the less bugs your have. Of course, you can cost cut by not having checks and hope that your product is still bug free. This is quite unlikely especially if the software is complex.

Personally, I prefer to write user stories and scenarios first rather than spending time coding the tests. Think of them as pseudocode. Once we have the user stories and scenarios defined, we will jump in and code functionality A. When functionality A is completed, we will code the test cases and ensure they pass before moving on. We will repeat the cycle for functionality B before moving on to functionality C. The idea is to not break existing functionalities while adding on new functionalities.

Everyone’s testing approach is different. You could implement your own approach.

There are many frameworks for acceptance testing. Behat and Mink are the industrial standard at the moment. In this book, we will be using Codeception to write acceptance tests in most cases. We will also be writing some functional test in phpunit.

Installation

1 -> cd symfony
2 # only thing about running docker is that for anything relating to db connection,
3 # we need to execute commands in the docker instance. we will create a wrapper f\
4 or this in the future
5 -> docker-compose exec php composer require codeception/codeception --dev

The “–dev” means we only need this in dev mode. If everything is working, you will see composer adding the dependency in composer.json

1 # symfony/composer.json
2 
3 # add the codeception line under require-dev
4 "require-dev": {
5     ...
6     "codeception/codeception": "^2.2"
7 },

Now we can initialise codeception

1 -> vendor/bin/codecept bootstrap

Let us configure the acceptance test.

 1 # symfony/tests/acceptance.suite.yml
 2 class_name: AcceptanceTester
 3 modules:
 4     enabled:
 5         - WebDriver:
 6             url: 'http://songbird.app'
 7             host: 172.25.0.5
 8             port: 4444
 9             browser: phantomjs
10             window_size: 1024x768
11             capabilities:
12                 unexpectedAlertBehaviour: 'accept'
13                 webStorageEnabled: true
14         - \Helper\Acceptance

Acceptance Testing is like Black Box Testing - We try to simulate real users interacting with our app. We ignore the inner workings of the code and only care if it works from the end user’s point of view.

Here, we are using the headless browser - phantomjs to connect to the webserver at 172.25.0.5 (see the docker-compose.yml file). Codeception by default comes with PhpBrowser which doesn’t support javascript. Selenium is slow but is the veteran when comes to acceptance testing. Feel free to switch to selenium if you encounter problems.

We can now generate the acceptance actions based on the updated acceptance suite:

 1 -> vendor/bin/codecept build
 2 
 3 # we will now get all the codecept libraries for free
 4 
 5 Building Actor classes for suites: acceptance, functional, unit
 6  -> AcceptanceTesterActions.php generated successfully. 0 methods added
 7 \AcceptanceTester includes modules: WebDriver, \Helper\Acceptance
 8  -> FunctionalTesterActions.php generated successfully. 0 methods added
 9 \FunctionalTester includes modules: \Helper\Functional
10  -> UnitTesterActions.php generated successfully. 0 methods added
11 \UnitTester includes modules: Asserts, \Helper\Unit

The First Test

We know that the default Symfony comes with the AppBundle example. Let us now test the bundle by creating a test suite for it.

1 -> vendor/bin/codecept generate:cest acceptance AppBundle

The auto generated Cest class should look like this:

 1 # symfony/tests/acceptance/AppBundleCest.php
 2 
 3 class AppBundleCest
 4 {
 5     public function _before(AcceptanceTester $I)
 6     {
 7     }
 8 
 9     public function _after(AcceptanceTester $I)
10     {
11     }
12 
13    ...
14 }

Let us write our own test. All new Symfony installation homepage should have a successful message.

1 # symfony/tests/acceptance/AppBundleCest.php
2 ...
3 # replaced tryToTest function with InstallationTest function
4 public function InstallationTest(AcceptanceTester $I)
5 {
6     $I->wantTo('Check if Symfony is installed successfully.');
7     $I->amOnPage('/');
8     $I->see('Welcome to');
9 }

We have been running the codecept command from the host machine. That is fine but we should really be running the command in the php docker container. In the symfony dir, we need to softlink the .env file as we are going to run docker commands in that dir. If not, you will get a bunch of environment variables not found error.

1 # in the symfony dir
2 -> ln -s ../.env

Now run the test:

 1 -> docker-compose exec php vendor/bin/codecept run acceptance AppBundleCest
 2 
 3 Codeception PHP Testing Framework v2.2.8
 4 Powered by PHPUnit 5.7.5 by Sebastian Bergmann and contributors.
 5 
 6 Acceptance Tests (1) -----------------------------------------------------------\
 7 -------------
 8 Testing acceptance
 9 ✔ AppBundleCest: Check if symfony is installed successfully. (4.82s)
10 --------------------------------------------------------------------------------\
11 -------------
12 
13 
14 Time: 5.86 seconds, Memory: 13.50MB
15 
16 OK (1 test, 1 assertion)

Some files such as images are binary. We need to tell git not to convert the line endings (google for it if interested)

1 # .gitattributes
2 
3 ...
4 # Denote all files that are truly binary and should not be modified.
5 *.png binary
6 *.jpg binary

Don’t forget to commit your code before moving on to the next chapter.

1 -> git add symfony
2 -> git commit -m"added codeception and created basic test"
3 # update remote repo so you dont lose it
4 -> git push -u origin my_chapter4

Summary

In this chapter, we discussed the importance of testing and touched on TDD and BDD. In our context, we will be mainly writing BDD tests. We installed codeception and wrote a simple acceptance test to tests the default symfony home page.

Exercises (Optional)

  • Try configure codeception to allow the running of different acceptance testing profiles. Can you test with PhpBrowser or selenium easily? Do you see any benefit of doing that? See advanced codeception for help.

Resources

Chapter 5: The Testing Framework Part 2 (Optional)

This chapter talks about Codeception. Feel free to skip it if you already have a testing framework in place.

Since we are ready to build the application, let us remove the route for the default homepage and we are going to make sure that we have done that correctly.

Modifying DefaultController.php

Previously, we could access the route “/” because the route exists in DefaultController.php. Removing the @route annotation will remove the route. A simple trick to do that is to take out the @.

 1 #  symfony/src/AppBundle/Controller/DefaultController.php
 2 
 3 use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
 4 use Symfony\Bundle\FrameworkBundle\Controller\Controller;
 5 use Symfony\Component\HttpFoundation\Request;
 6 
 7 class DefaultController extends Controller
 8 {
 9     /**
10      * not using the homepage route for now
11      *
12      * Route("/", name="homepage")
13      */
14     public function indexAction(Request $request)
15     {
16         // replace this example code with whatever you need
17         return $this->render('default/index.html.twig', array(
18             'base_dir' => realpath($this->container->getParameter('kernel.root_d\
19 ir').'/..'),
20         ));
21     }
22 }

Now, refresh http://songbird.app:8000/app_dev.php and you should see a 404 error.

1 No route found for "GET /"

This is correct because the url is no longer configured. How can you be sure? Let us check it out from the command line

 1 # in symfony
 2 -> docker-compose exec php bin/console debug:router
 3 
 4 [router] Current routes
 5  Name                     Method Scheme Host Path
 6  _wdt                     ANY    ANY    ANY  /_wdt/{token}
 7  _profiler_home           ANY    ANY    ANY  /_profiler/
 8  _profiler_search         ANY    ANY    ANY  /_profiler/search
 9  _profiler_search_bar     ANY    ANY    ANY  /_profiler/search_bar
10  _profiler_purge          ANY    ANY    ANY  /_profiler/purge
11  _profiler_info           ANY    ANY    ANY  /_profiler/info/{about}
12  _profiler_phpinfo        ANY    ANY    ANY  /_profiler/phpinfo
13  _profiler_search_results ANY    ANY    ANY  /_profiler/{token}/search/results
14  _profiler                ANY    ANY    ANY  /_profiler/{token}
15  _profiler_router         ANY    ANY    ANY  /_profiler/{token}/router
16  _profiler_exception      ANY    ANY    ANY  /_profiler/{token}/exception
17  _profiler_exception_css  ANY    ANY    ANY  /_profiler/{token}/exception.css
18  _configurator_home       ANY    ANY    ANY  /_configurator/
19  _configurator_step       ANY    ANY    ANY  /_configurator/step/{index}
20  _configurator_final      ANY    ANY    ANY  /_configurator/final
21  _twig_error_test         ANY    ANY    ANY  /_error/{code}.{_format}

Looks like there is no trace of the / path. This is all good but to do a proper job, we have to make sure that this logic is remembered in the future. We need to record this logic in a test.

Hang on, did you realise how ugly the command line is?

docker-compose exec php bin/console debug:router

What we are doing here is that we are trying to access bin/console within the php docker instance. If you have php 7 installed in your host, you could run bin/console straight away and achieve the same results.

We will create a much simple wrapper around the docker commands in a minute.

Making sure the / route is removed

To make sure that / route is correctly removed and not accidentally added again in the future, let us add a test for it.

 1 # symfony/tests/acceptance/AppBundleCest.php
 2 ...
 3 # replace installationTest with removalTest
 4 
 5    /**
 6      * check that homepage is not active
 7      *
 8      * @param AcceptanceTester $I
 9      */
10     public function RemovalTest(AcceptanceTester $I)
11     {
12         $I->wantTo('Check if / is not active.');
13         $I->amOnPage('/');
14         $I->see('404 Not Found');
15     }

and run the test again,

1 # clear prod cache because test is running in prod env
2 -> docker-compose exec php bin/console cache:clear --env=prod
3 
4 # remember to start phantomjs server before running this command
5 -> docker-compose exec php vendor/bin/codecept run acceptance
6 ...
7 Time: 10.47 seconds, Memory: 11.50MB
8 
9 OK (1 test, 1 assertion)

Creating custom bash script to run acceptance test

We have to remember to clear the cache every time we run the test so that we don’t test on the cached version. Let us automate this by creating a script in the scripts dir called “runtest” and make it executable.

1 # in symfony
2 -> touch scripts/runtest
3 -> chmod u+x scripts/runtest

In the runtest script,

1 # symfony/scripts/runtest
2 
3 #!/bin/bash
4 
5 docker-compose exec php bin/console cache:clear --no-warmup
6 docker-compose exec php vendor/bin/codecept run acceptance

Now test your automation by running

1 -> ./scripts/runtest
2 ...
3 OK (1 test, 1 assertion)

We are almost done. Remember to commit all your changes before moving on to the next chapter.

Summary

In this chapter, we have removed the default / route and updated our test criteria. We have also created a few bash scripts to automate the task of running codecept test. We will add more to these scripts in the future.

References

Chapter 6: The User Management System Part 1

User Management System is the core part of any CMS. We will create this feature using the popular FOSUserBundle.

Pre-setup

Make sure we are in the right branch. Let us branch off from the previous chapter.

1 -> git checkout -b my_chapter6

Installing the FOSUserBundle

Add the bundle in composer.json

1 # in symfony
2 -> docker-compose exec php composer require friendsofsymfony/user-bundle ~2.0@dev

Now in AppKernel, we need to register the bundles

 1 # app/AppKernel.php
 2 ...
 3 public function registerBundles()
 4 {
 5     $bundles = array(
 6         ...
 7         new AppBundle\AppBundle(),
 8         // init my fosuser
 9         new FOS\UserBundle\FOSUserBundle(),
10         new AppBundle\User()
11     );
12 }

AppBundleUser() will look for the User class in User.php (under the AppBundle namespace). We want the User class to inherit all properties of FOSUserBundle. Let us create User.php

 1 # src/AppBundle/User.php
 2 
 3 namespace AppBundle;
 4 
 5 use Symfony\Component\HttpKernel\Bundle\Bundle;
 6 
 7 class User extends Bundle
 8 {
 9     // use a child bundle
10     public function getParent()
11     {
12 	return 'FOSUserBundle';
13     }
14 }

Next we need to configure FOSUserBundle. Don’t worry if certain directives don’t make sense. It will as you progress further. Note that yaml files cannot contain tabs.

 1 # app/config/config.yml
 2 ...
 3 # turn on translator
 4 translator:      { fallbacks: ["%locale%"] }
 5 ...
 6 
 7 framework:
 8     ...
 9     session:
10         # http://symfony.com/doc/current/reference/configuration/framework.html#\
11 handler-id
12         # we have to use the system session storage because the default doesn't \
13 work with vagrant.
14         handler_id:  ~
15         # save_path:   "%kernel.root_dir%/../var/sessions/%kernel.environment%"
16         
17 # fosuser config
18 fos_user:
19     db_driver: orm
20     firewall_name: main
21     user_class: AppBundle\Entity\User
22     from_email:
23         address: admin@songbird.app
24         sender_name: Songbird

and setup the security and firewall, your file should look like this

 1 # app/config/security.yml
 2 security:
 3   encoders:
 4           FOS\UserBundle\Model\UserInterface: bcrypt
 5 
 6   # http://symfony.com/doc/current/book/security.html#where-do-users-come-from-u\
 7 ser-providers
 8   providers:
 9       fos_userbundle:
10           id: fos_user.user_provider.username
11 
12   role_hierarchy:
13           ROLE_ADMIN:       ROLE_USER
14           ROLE_SUPER_ADMIN: ROLE_ADMIN
15 
16   firewalls:
17       # disables authentication for assets and the profiler, adapt it according \
18 to your needs
19       dev:
20           pattern: ^/(_(profiler|wdt)|css|images|js)/
21           security: false
22 
23       main:
24           pattern: ^/
25           form_login:
26               provider: fos_userbundle
27               csrf_token_generator: security.csrf.token_manager
28           logout:       true
29           anonymous:    true
30 
31   access_control:
32           - { path: ^/login$, role: IS_AUTHENTICATED_ANONYMOUSLY }
33           - { path: ^/register, role: IS_AUTHENTICATED_ANONYMOUSLY }
34           - { path: ^/resetting, role: IS_AUTHENTICATED_ANONYMOUSLY }
35           - { path: ^/admin/, role: ROLE_ADMIN }

DB credentials

The db credentials are in app/config/parameters.yml. They are usually variables based on your environment. Since we are using docker, we can hard code them.

Have a look at the file if you are interested.

 1 # symfony/app/config/parameters.yml
 2 
 3 # your db host is the container in your docker environment
 4 # run "docker network inspect songbird_mynet" to see the ip of the mysql instanc\
 5 e.
 6 
 7 parameters:
 8     database_host: 172.25.0.2
 9     database_port: 3306
10     database_name: songbird
11     database_user: root
12     database_password: root
13     mailer_transport: smtp
14     mailer_host: 172.25.0.6:1025
15     mailer_user: null
16     mailer_password: null
17     secret: ThisTokenIsNotSoSecretChangeIt

Creating the User Entity

Have a quick read if you are unfamiliar with doctrine and entity. We will be using doctrine very often in this book.

Symfony allows us to automate lots of things using command line, including the creation of entities. We will create the user entity with 2 custom fields called firstname and lastname.

 1 -> docker-compose exec php bin/console generate:doctrine:entity
 2 
 3 # You will be prompted a series of questions.
 4 
 5 The Entity shortcut name: AppBundle:User
 6 
 7 Configuration format (yml, xml, php, or annotation) [annotation]: annotation
 8 
 9 New field name (press <return> to stop adding fields): firstname
10 Field type [string]:
11 Field length [255]:
12 Is nullable [false]: true
13 Unique [false]:
14 
15 New field name (press <return> to stop adding fields): lastname
16 Field type [string]:
17 Field length [255]:
18 Is nullable [false]: true
19 Unique [false]:

You realised we have to use “docker-compose exec php” to run commands in the php container. Its ugly and we will automate that in a minute. Once you are familiar with the command line, you should be able to generate the entity and other files without prompts. We will be doing that in the future chapters.

FOSUserBundle Groups are useful when you want to group users together. For the sake of simplicity, we won’t be using this feature. However, you should be able to add this feature in easily once you are comfortable with the Symfony workflow.

Now, the entity class is generated under src/AppBundle/Entity folder. We need to extend the fosuserbundle and make the id protected because of inheritance. If you open up the file, you will see that the code has been created for you already but we still need to make some changes in order for the entity inheritance to work. Refer to comments in the code.

  1 namespace AppBundle\Entity;
  2 
  3 # add baseuser
  4 use FOS\UserBundle\Model\User as BaseUser;
  5 use Doctrine\ORM\Mapping as ORM;
  6 
  7 /**
  8  * User extending fos user
  9  *
 10  * @ORM\Table(name="user")
 11  * @ORM\Entity(repositoryClass="AppBundle\Repository\UserRepository")
 12  */
 13 class User extends BaseUser
 14 {
 15     /**
 16      * Needs to be protected because of inheritance
 17      *
 18      * @var int
 19      *
 20      * @ORM\Column(name="id", type="integer")
 21      * @ORM\Id
 22      * @ORM\GeneratedValue(strategy="AUTO")
 23      */
 24     protected $id;
 25 
 26     /**
 27      * @var string
 28      *
 29      * @ORM\Column(name="firstname", type="string", length=255, nullable=true)
 30      */
 31     private $firstname;
 32 
 33     /**
 34      * @var string
 35      *
 36      * @ORM\Column(name="lastname", type="string", length=255, nullable=true)
 37      */
 38     private $lastname;
 39 
 40     /**
 41      * User constructor.
 42      */
 43     public function __construct()
 44     {
 45         parent::__construct();
 46     }
 47 
 48     /**
 49      * Get id
 50      *
 51      * @return int
 52      */
 53     public function getId()
 54     {
 55         return $this->id;
 56     }
 57 
 58     /**
 59      * Set firstname
 60      *
 61      * @param string $firstname
 62      *
 63      * @return User
 64      */
 65     public function setFirstname($firstname)
 66     {
 67         $this->firstname = $firstname;
 68 
 69         return $this;
 70     }
 71 
 72     /**
 73      * Get firstname
 74      *
 75      * @return string
 76      */
 77     public function getFirstname()
 78     {
 79         return $this->firstname;
 80     }
 81 
 82     /**
 83      * Set lastname
 84      *
 85      * @param string $lastname
 86      *
 87      * @return User
 88      */
 89     public function setLastname($lastname)
 90     {
 91         $this->lastname = $lastname;
 92 
 93         return $this;
 94     }
 95 
 96     /**
 97      * Get lastname
 98      *
 99      * @return string
100      */
101     public function getLastname()
102     {
103         return $this->lastname;
104     }
105 }

You will noticed all the getters and setters have already been generated for you as well. Cool!

Now, we need to configure the routes. The default routes provided by FOSUser is a good start.

 1 # app/config/routing.yml
 2 ...
 3 # FOS user bundle default routing
 4 fos_user_security:
 5     resource: "@FOSUserBundle/Resources/config/routing/security.xml"
 6 
 7 fos_user_profile:
 8     resource: "@FOSUserBundle/Resources/config/routing/profile.xml"
 9     prefix: /profile
10 
11 fos_user_resetting:
12     resource: "@FOSUserBundle/Resources/config/routing/resetting.xml"
13     prefix: /resetting
14 
15 fos_user_change_password:
16     resource: "@FOSUserBundle/Resources/config/routing/change_password.xml"
17     prefix: /profile

To check that the new routes have been installed correctly,

 1 # in symfony
 2 -> bin/console debug:router | grep fos
 3 
 4  fos_user_security_login           GET|POST ANY    ANY  /login
 5  fos_user_security_check           POST     ANY    ANY  /login_check
 6  fos_user_security_logout          GET      ANY    ANY  /logout
 7  fos_user_profile_show             GET      ANY    ANY  /profile/
 8  fos_user_profile_edit             GET|POST ANY    ANY  /profile/edit
 9  fos_user_resetting_request        GET      ANY    ANY  /resetting/request
10  fos_user_resetting_send_email     POST     ANY    ANY  /resetting/send-email
11  fos_user_resetting_check_email    GET      ANY    ANY  /resetting/check-email
12  fos_user_resetting_reset          GET|POST ANY    ANY  /resetting/reset/{token}
13  fos_user_change_password          GET|POST ANY    ANY  /profile/change-password

or we can use the router:match command to match the exact url and get more details

 1 # in symfony
 2 -> bin/console router:match /profile/
 3 Route "fos_user_profile_show" matches
 4 
 5 [router] Route "fos_user_profile_show"
 6 Name         fos_user_profile_show
 7 Path         /profile/
 8 Path Regex   #^/profile/$#s
 9 Host         ANY
10 Host Regex
11 Scheme       ANY
12 Method       GET
13 Class        Symfony\Component\Routing\Route
14 Defaults     _controller: FOSUserBundle:Profile:show
15 Requirements NO CUSTOM
16 Options      compiler_class: Symfony\Component\Routing\RouteCompiler

See how much work has done for you by inheriting the FOSUserBundle… This step allows you to use many default FOSUserBundle functionalities like password reset and user profile update without writing a single line of code! Now, let us test one of the routes by going to

1 http://songbird.app:8000/app_dev.php/login

You should see a simple login page.

To verify that the schema is correct, let us generate it:

1 # in symfony
2 -> docker-compose exec php bin/console doctrine:schema:create
3 
4 Creating database schema...
5 Database schema created successfully!

Let us check that the schema has indeed been created correctly.

 1 -> docker-compose exec db mysql -uroot -proot songbird -e "describe user"
 2 +-----------------------+--------------+------+-----+---------+----------------+
 3 | Field                 | Type         | Null | Key | Default | Extra          |
 4 +-----------------------+--------------+------+-----+---------+----------------+
 5 | id                    | int(11)      | NO   | PRI | NULL    | auto_increment |
 6 | username              | varchar(180) | NO   |     | NULL    |                |
 7 | username_canonical    | varchar(180) | NO   | UNI | NULL    |                |
 8 | email                 | varchar(180) | NO   |     | NULL    |                |
 9 | email_canonical       | varchar(180) | NO   | UNI | NULL    |                |
10 | enabled               | tinyint(1)   | NO   |     | NULL    |                |
11 | salt                  | varchar(255) | YES  |     | NULL    |                |
12 | password              | varchar(255) | NO   |     | NULL    |                |
13 | last_login            | datetime     | YES  |     | NULL    |                |
14 | confirmation_token    | varchar(180) | YES  | UNI | NULL    |                |
15 | password_requested_at | datetime     | YES  |     | NULL    |                |
16 | roles                 | longtext     | NO   |     | NULL    |                |
17 | firstname             | varchar(255) | YES  |     | NULL    |                |
18 | lastname              | varchar(255) | YES  |     | NULL    |                |
19 +-----------------------+--------------+------+-----+---------+----------------+

Looks like we got the right fields. Let us now create a console wrapper to make our life easier.

Wrapper Scripts

We now need a very simple wrapper to run the console commands. Let us create a console wrapper.

1 # in symfony/scripts/console
2 
3 #!/bin/bash 
4 docker-compose exec php bin/console $@

once the console script is created, it needs to be executable.

1 # in symfony
2 chmod u+x scripts/console

Let us try some commands

1 # you should not see an error
2 ./scripts/console debug:router

Let us do the same for the composer command

1 # in symfony/scripts/composer
2 
3 #!/bin/bash 
4 docker-compose exec php composer "$@"

and

1 # in symfony
2 chmod u+x scripts/composer

Finally, we will create another for the mysql command

1 # in symfony/scripts/mysql
2 
3 #!/bin/bash 
4 
5 MYSQL_DATABASE=`grep MYSQL_DATABASE .env | cut -d= -f 2`
6 MYSQL_ROOT_PASSWORD=`grep MYSQL_ROOT_PASSWORD .env | cut -d= -f 2`
7 
8 docker-compose exec db mysql -uroot -p$MYSQL_ROOT_PASSWORD $MYSQL_DATABASE -e "$\
9 @"

now we allow executable bit to this script.

1 # in symfony
2 chmod u+x scripts/mysql

We can now use some wrapper scripts to access the php container easily. We are gearing up. Ready for more?

Summary

In this chapter, we have installed FOSUserBundle and extended it in AppBundle. We have verified that the installation was correct by looking at the default login page and database schema. We also created some helper scripts to help accessing the docker instance a bit easier.

Remember to commit all your changes before moving on.

Exercises (Optional)

  • Try installing the UserBundle outside of Appbundle. Are there any pros and cons of doing that as compared to putting all the bundles in AppBundle?

References

Chapter 7: The User Management System Part 2

We have installed the FOSUserBundle but it looks like there are still big chunks of functionalities missing. How do we (C)reate, (R)ead, (U)pdate and (D)elete a user or group for example?

You see the word “CRUD” appearing so many times because it is part of RAD. All frameworks today come with auto CRUD generation.

Automated User CRUD Generation

We will generate CRUD for the UserBundle.

 1 -> ./scripts/console doctrine:generate:crud
 2 
 3 The Entity shortcut name: AppBundle:User
 4 
 5 By default, the generator creates two actions: list and show.
 6 You can also ask it to generate "write" actions: new, update, and delete.
 7 
 8 Do you want to generate the "write" actions [no]? yes
 9 
10 Determine the format to use for the generated CRUD.
11 
12 Configuration format (yml, xml, php, or annotation) [annotation]: annotation
13 
14 Determine the routes prefix (all the routes will be "mounted" under this
15 prefix: /prefix/, /prefix/new, ...).
16 
17 Routes prefix [/user]:
18 
19 
20   Summary before generation
21 
22 
23 You are going to generate a CRUD controller for "AppBundle:User"
24 using the "annotation" format.
25 
26 Do you confirm generation [yes]?
27 
28 
29   CRUD generation
30 
31 
32 Generating the CRUD code: OK

Now go to

1 http://songbird.app:8000/app_dev.php/user/

We haven’t added any data yet. The database should be empty as per the previous chapter.

Let us add some data. Click on “Create a new entry” or go to

1 http://songbird.app:8000/app_dev.php/user/new

and enter a dummy firstname and lastname, then click create.

You should see a “Integrity constraint violation: 1048 Column ‘username’ cannot be null” error. Why?

I am going to skip through all technicalities for now and tell you where the answer is. Look at

 1 # vendor/friendsofsymfony/user-bundle/Resources/config/validation.xml
 2 ...
 3 <property name="username">
 4     <constraint name="NotBlank">
 5         <option name="message">fos_user.username.blank</option>
 6         <option name="groups">
 7             <value>Registration</value>
 8             <value>Profile</value>
 9         </option>
10         ...

It is possible to create a new user from command line, the code is at:

 1 # vendor/friendsofsymfony/user-bundle/Command/CreateUserCommand.php
 2 ...
 3 class CreateUserCommand extends ContainerAwareCommand
 4 {
 5     /**
 6      * @see Command
 7      */
 8     protected function configure()
 9     {
10         $this
11             ->setName('fos:user:create')
12             ->setDescription('Create a user.')
13             ->setDefinition(array(
14                 new InputArgument('username', InputArgument::REQUIRED, 'The user\
15 name'),
16                 new InputArgument('email', InputArgument::REQUIRED, 'The email'),
17                 new InputArgument('password', InputArgument::REQUIRED, 'The pass\
18 word'),

Did you remember that the “fos:user:create” command is available under the scripts/console command? You can infer from these lines that username, email and password are compulsory. How do we add these extra fields in the user form?

Adding Fields to the User Form

The extra FOSUserBundle fields were not automatically added when we created the CRUD using the command line. The automated CRUD creation process cannot pick up inheritance yet (I hope one day it will). We have to create the fields manually.

 1 # src/AppBundle/Form/UserType.php
 2 namespace AppBundle\Form;
 3 
 4 use Symfony\Component\Form\AbstractType;
 5 use Symfony\Component\Form\FormBuilderInterface;
 6 use Symfony\Component\OptionsResolver\OptionsResolver;
 7 use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
 8 use Symfony\Component\Form\Extension\Core\Type\PasswordType;
 9 
10 class UserType extends AbstractType
11 {
12     /**
13      * @param FormBuilderInterface $builder
14      * @param array $options
15      */
16     public function buildForm(FormBuilderInterface $builder, array $options)
17     {
18         $builder
19             ->add('username')
20             ->add('email')
21             ->add('firstname')
22             ->add('lastname')
23             ->add('password', RepeatedType::class, array(
24                 'type' => PasswordType::class,
25                 'invalid_message' => 'The password fields must match.',
26                 'required' => true,
27                 'first_options'  => array('label' => 'Password'),
28                 'second_options' => array('label' => 'Repeat Password'),
29             ))
30         ;
31     }
32     ...

Refresh the browser and if changes are not showing up, we need to delete the cache.

1 -> ./scripts/console cache:clear

This cache:clear command is equivalent to “rm -rf var/cache/dev”. It is a useful alternative to clear:cache. If no environment is set, the environment is set to develop. To delete prod cache,

1 -> ./scripts/console cache:clear -e prod

Let us create 2 test users, say “test” and “test1”

We can now list them by going to /user

Now verify that the new data is inserted into the user table by running some sql

1 -> ./scripts/mysql "select id,username,password from user"
2 
3 +----+----------+----------+
4 | id | username | password |
5 +----+----------+----------+
6 |  1 | test     | test     |
7 |  2 | test1    | test1    |
8 +----+----------+----------+

Wow, why was the password exposed? shouldn’t the password be encrypted automatically?

No, because the CRUD that we have created previously didn’t know that the password was supposed to be encrypted before inserting into the db. Fortunately, FOSUserBundle has a service container that can help us with this. The word service is important in Symfony. Don’t worry about it for now as we will cover this in the following chapters.

For the sake of curiousity, let us see all the FOSUserBundle service containers.

 1 -> ./scripts/console debug:container | grep fos
 2 
 3  fos_user.change_password.form.factory                              FOSUserBundl\
 4 eFormFactoryFormFactory
 5  fos_user.change_password.form.type                                 FOSUserBundl\
 6 eFormTypeChangePasswordFormType
 7  fos_user.group.form.factory                                        FOSUserBundl\
 8 eFormFactoryFormFactory
 9  fos_user.group.form.type                                           FOSUserBundl\
10 eFormTypeGroupFormType
11  fos_user.group_manager                                             FOSUserBundl\
12 eDoctrineGroupManager
13  fos_user.listener.authentication                                   FOSUserBundl\
14 eEventListenerAuthenticationListener
15  fos_user.listener.flash                                            FOSUserBundl\
16 eEventListenerFlashListener
17  fos_user.listener.resetting                                        FOSUserBundl\
18 eEventListenerResettingListener
19  fos_user.mailer                                                    FOSUserBundl\
20 eMailerMailer
21  fos_user.profile.form.factory                                      FOSUserBundl\
22 eFormFactoryFormFactory
23  fos_user.profile.form.type                                         FOSUserBundl\
24 eFormTypeProfileFormType
25  fos_user.registration.form.factory                                 FOSUserBundl\
26 eFormFactoryFormFactory
27  fos_user.registration.form.type                                    FOSUserBundl\
28 eFormTypeRegistrationFormType
29  fos_user.resetting.form.factory                                    FOSUserBundl\
30 eFormFactoryFormFactory
31  fos_user.resetting.form.type                                       FOSUserBundl\
32 eFormTypeResettingFormType
33  fos_user.security.interactive_login_listener                       FOSUserBundl\
34 eEventListenerLastLoginListener
35  fos_user.security.login_manager                                    FOSUserBundl\
36 eSecurityLoginManager
37  fos_user.user_manager                                              FOSUserBundl\
38 eDoctrineUserManager
39  fos_user.username_form_type                                        FOSUserBundl\
40 eFormTypeUsernameFormType
41  fos_user.util.email_canonicalizer                                  FOSUserBundl\
42 eUtilCanonicalizer
43  fos_user.util.token_generator                                      FOSUserBundl\
44 eUtilTokenGenerator
45  fos_user.util.user_manipulator                                     FOSUserBundl\
46 eUtilUserManipulator

The logic for all user related actions is stored in FOSUserBundleDoctrineUserManager. The service for that class is fos_user.user_manager. Let us use the service in UserController.php

 1 # src/AppBundle/Controller/UserController.php
 2 ...
 3     /**
 4      * Creates a new User entity.
 5      *
 6      * @Route("/new", name="user_new")
 7      * @Method({"GET", "POST"})
 8      */
 9     public function newAction(Request $request)
10     {
11         $user = new User();
12         $form = $this->createForm('AppBundle\Form\UserType', $user);
13         $form->handleRequest($request);
14 
15         if ($form->isSubmitted() && $form->isValid()) {
16             // CHANGE HERE
17             $userManager = $this->get('fos_user.user_manager');
18             $user->setPlainPassword($user->getPassword());
19             $userManager->updateUser($user);
20             // $em = $this->getDoctrine()->getManager();
21             // $em->persist($user);
22             // $em->flush();
23 
24             return $this->redirectToRoute('user_show', array('id' => $user->getI\
25 d()));
26         }
27 
28         return $this->render('user/new.html.twig', array(
29             'user' => $user,
30             'form' => $form->createView(),
31         ));
32     }
33 ...
34     /**
35      * Displays a form to edit an existing User entity.
36      *
37      * @Route("/{id}/edit", name="user_edit")
38      * @Method({"GET", "POST"})
39      */
40     public function editAction(Request $request, User $user)
41     {
42         $deleteForm = $this->createDeleteForm($user);
43         $editForm = $this->createForm('AppBundle\Form\UserType', $user);
44         $editForm->handleRequest($request);
45         if ($editForm->isSubmitted() && $editForm->isValid()) {
46             // CHANGE HERE
47             $userManager = $this->get('fos_user.user_manager');
48             // we get the values that user submitted
49             $user->setPlainPassword($request->request->get('user')['password']['\
50 first']);
51             $userManager->updateUser($user);
52             // $em = $this->getDoctrine()->getManager();
53             // $em->persist($user);
54             // $em->flush();
55 
56             return $this->redirectToRoute('user_edit', array('id' => $user->getI\
57 d()));
58         }
59 
60         return $this->render('user/edit.html.twig', array(
61             'user' => $user,
62             'edit_form' => $editForm->createView(),
63             'delete_form' => $deleteForm->createView(),
64         ));
65     }
66 ...

The persist and flush statement in doctrine is a standard way to prepare and save queries to db. We have commented it off because if you look at the updateUser function in FOSUserBundleDoctrineUserManager, this part was already done.

Let us try creating a new user called “test3” and view it again in mysql

1 -> ./scripts/mysql "select id,username,password from user"
2 +----+----------+--------------------------------------------------------------+
3 | id | username | password                                                     |
4 +----+----------+--------------------------------------------------------------+
5 |  1 | test     | test                                                         |
6 |  2 | test1    | test1                                                        |
7 |  4 | test3    | $2y$13$ovAu1e0C.eLof9KDsXVKP.bFFGxb82.mHf156i6PXI.XwjP9EwBr2 |
8 +----+----------+--------------------------------------------------------------+

The test3 user password is now encrypted. Update the password of another user and you will see that the encryption is working.

What’s Up With Editing the User

Now, let’s try editing the test user. We are going to change the first name for example,

The form is stopping us from editing because the password is a compulsory field. How do we fix that?

Let us pass a passwordRequired variable into the UserType class. If the variable is false, the password field will not be compulsory.

 1 # src/AppBundle/Controller/UserController
 2 
 3 ...
 4     /**
 5      * Displays a form to edit an existing User entity.
 6      *
 7      * @Route("/{id}/edit", name="user_edit")
 8      * @Method({"GET", "POST"})
 9      */
10     public function editAction(Request $request, User $user)
11     {
12         $deleteForm = $this->createDeleteForm($user);
13         // ADD a new passwordRequired variable to the UserType Class
14         $editForm = $this->createForm('AppBundle\Form\UserType', $user, array('p\
15 asswordRequired' => false));
16         $editForm->handleRequest($request);
17         ...
18     }
19 ...

and in UserType.php,

 1 namespace AppBundle\Form;
 2 
 3 use Symfony\Component\Form\AbstractType;
 4 use Symfony\Component\Form\FormBuilderInterface;
 5 use Symfony\Component\OptionsResolver\OptionsResolver;
 6 use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
 7 use Symfony\Component\Form\Extension\Core\Type\PasswordType;
 8 
 9 class UserType extends AbstractType
10 {
11     /**
12      * @param FormBuilderInterface $builder
13      * @param array $options
14      */
15     public function buildForm(FormBuilderInterface $builder, array $options)
16     {
17         $builder
18             ->add('username')
19             ->add('email')
20             ->add('firstname')
21             ->add('lastname')
22             ->add('password', RepeatedType::class, array(
23                 'type' => PasswordType::class,
24                 'invalid_message' => 'The password fields must match.',
25                 // New passwordRequired variable
26                 'required' => $options['passwordRequired'],
27                 'first_options'  => array('label' => 'Password'),
28                 'second_options' => array('label' => 'Repeat Password'),
29             ))
30         ;
31     }
32 
33     /**
34      * @param OptionsResolver $resolver
35      */
36     public function configureOptions(OptionsResolver $resolver)
37     {
38         $resolver->setDefaults(array(
39             'data_class' => 'AppBundle\Entity\User',
40             // Add new variable
41             'passwordRequired' => true,
42         ));
43     }
44 }

If the password field is null, it means that user doesn’t want to update the password. We will need to override FOSUserBundle setPassword function.

 1 # src/AppBundle/Entity/User.php
 2 ...
 3     /**
 4      * Override parent's method. Don't set passwd if its null.
 5      *
 6      * @param string $password
 7      * @return $this
 8      */
 9     public function setPassword($password)
10     {
11         if ($password) {
12             $this->password = $password;
13         }
14         return $this;
15     }
16 ...

Updating Doctrine Fields Automatically

We like to have 2 more fields. We like to know when the user is being created and updated. How do we do that? HasLifeCycleCallBacks annotation is the magic.

 1 # src/AppBundle/Entity/User.php
 2 ...
 3 /**
 4  * User
 5  *
 6  * @ORM\Table()
 7  * @ORM\Entity(repositoryClass="AppBundle\Entity\UserRepository")
 8  * @ORM\HasLifecycleCallbacks()
 9  */
10 class User extends BaseUser
11 {
12 	...
13 	/**
14 	 * @ORM\Column(type="datetime")
15 	 */
16 	private $modified;
17 
18 	/**
19 	 * @ORM\Column(type="datetime")
20 	 */
21 	private $created;
22 
23 	/**
24 	 * @ORM\PrePersist
25 	 */
26 	public function prePersist()
27 	{
28 	    // update the modified time
29 	    $this->setModified(new \DateTime());
30 
31 	    // for newly created entries
32 	    if ($this->getCreated() == null) {
33 	        $this->setCreated(new \DateTime('now'));
34 	    }
35 	}
36 
37     /**
38      * @ORM\PreUpdate
39      */
40     public function preUpdate()
41     {
42         // update the modified time
43         $this->setModified(new \DateTime());
44     }
45 ...

The “@ORMHasLifecycleCallbacks()” tells doctrine to run callback functions (in this case, prePersist or preUpdate) before creating or updating an entry.

Let us auto-generate the setters and getters for the new $modified and $created variables.

1 -> ./scripts/console doctrine:generate:entities --no-backup AppBundle:User

The –no-backup option tells the command not to back up your original entity file.

Verify that the new getters and setters for $created and $modified have been added to src/AppBundle/Entity/User.php. The schema is now changed and we need to update it.

1 # run this and you will see what the sql is doing
2 -> ./scripts/console doctrine:schema:update --dump-sql
3 
4 # once you are comfortable with that, force update it
5 -> ./scripts/console doctrine:schema:update --force

Try adding a new user and see if the created and modified time have been updated.

 1 -> ./scripts/mysql "select id,password,modified,created from user"
 2 +----+--------------------------------------------------------------+-----------\
 3 ----------+---------------------+
 4 | id | password                                                     | modified  \
 5           | created             |
 6 +----+--------------------------------------------------------------+-----------\
 7 ----------+---------------------+
 8 |  1 | $2y$13$.yitE0Zj6kK9zJ6DYS7X0eYZMY7MfRR97OCwvTbjn59tfr4dPuOZG | 2017-01-27\
 9  06:39:08 | 2017-01-27 06:38:17 |
10 +----+--------------------------------------------------------------+-----------\
11 ----------+---------------------+

Deleting Users

No problem. This should work out of the box. Test it out in your browser to convince yourself.

Cleaning Up

let us clean up the Controller by deleting the DefaultController.php

1 -> git rm src/AppBundle/Controller/DefaultController.php

and we need to update our runtest script

1 # symfony/scripts/runtest
2 
3 #!/bin/bash
4 
5 scripts/console cache:clear --no-warmup
6 docker-compose exec php vendor/bin/codecept run acceptance

Run a quick test again and make sure that whatever you have done doesn’t break anything. Still remember how to do it?

1 -> scripts/runtest
2 ...
3 
4 Time: 2.78 seconds, Memory: 13.50MB
5 
6 OK (1 test, 1 assertion)

You will soon realised you need a consistent set of test data to make testing easier. That is why data fixtures are so important.

Summary

We have created User CRUD using command line, digged into the code and fixed up a few things. Even though things still doesn’t work out of the box, we owed a lot to RAD to help us create a user management system in a short time. In reality, most CMS should allow you to configure user management system out of the box. It is still a good practice for us to go through it.

In addition to the basic CRUD, we have added 4 extra fields (firstname, lastname, created, modified). Unlike username, email and password fields, the firstname and lastname fields are not compulsory. On the edit page, the password field is also not compulsory.

Remember to commit all your changes before moving on.

Exercises (Optional)

  • FOSUserBundle provides a functionality to manage users via command line. Try adding a user from the command line.
  • Looking at AppBundleFormUserType, what happens if you change the password field to be called “plainPassword” instead? What changes would you make to the UserController.php class if that is the case?
  • Can you think of another way to pass variable from the controller to the form?

References

Chapter 8: Doctrine Fixtures and Migrations

As of now, we could create and manage users via the command line (scripts/console fos:user:xxx) or using the basic CRUD UI that we have created. What if we messed up the data or if we want to reset the data with certain values confidently? How can we do that efficiently? We need an automation mechanism to create consistent schema and dummy data.

Install DoctrineFixturesBundle

Install via composer

1 -> ./scripts/composer require doctrine/doctrine-fixtures-bundle ^2.3 --dev

Now that the data-fixtures-bundle is installed, we can update the kernel.

1 # app/AppKernel.php
2 
3 ...
4 if (in_array($this->getEnvironment(), array('dev', 'test'))) {
5     ...
6     $bundles[] = new Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle();
7     ...
8 }
9 ...

We register the bundle under the array(‘dev’, ‘test’) environment because we don’t need this bundle in the production environment.

To prove that the install is successful, we should have a new entry in the console

1 -> ./scipts/console | grep fixtures
2   doctrine:fixtures:load               Load data fixtures to your database.

Create User Fixtures

Let’s create the the data fixtures directory structure

1 -> mkdir -p src/AppBundle/DataFixtures/ORM

Now create the class. We are going to create 3 users. One super admin, 3 test users, ie test1, test2 and test3.

The username and password for the 3 users are as follows:

1 # in this format, username:password
2 admin:admin
3 test1:test1
4 test2:test2
5 test3:test3

Remember these 3 users credentials as we will be using them a lot throughout the whole book.

Now the actual fixtures class:

 1 # src/AppBundle/DataFixtures/ORM/LoadUserData.php
 2 
 3 namespace AppBundle\DataFixtures\ORM;
 4 
 5 use Doctrine\Common\DataFixtures\AbstractFixture;
 6 use Doctrine\Common\DataFixtures\OrderedFixtureInterface;
 7 use Doctrine\Common\Persistence\ObjectManager;
 8 use Symfony\Component\DependencyInjection\ContainerAwareInterface;
 9 use Symfony\Component\DependencyInjection\ContainerInterface;
10 
11 class LoadUserData extends AbstractFixture implements OrderedFixtureInterface, C\
12 ontainerAwareInterface
13 {
14 
15     /**
16      * @var ContainerInterface
17      */
18     private $container;
19 
20     /**
21      * {@inheritDoc}
22      */
23     public function setContainer(ContainerInterface $container = null)
24     {
25         $this->container = $container;
26     }
27 
28     /**
29      * {@inheritDoc}
30      */
31     public function load(ObjectManager $manager)
32     {
33         $userManager = $this->container->get('fos_user.user_manager');
34 
35         // add admin user
36         $admin = $userManager->createUser();
37         $admin->setUsername('admin');
38         $admin->setEmail('admin@songbird.app');
39         $admin->setPlainPassword('admin');
40         $userManager->updatePassword($admin);
41         $admin->setEnabled(1);
42         $admin->setFirstname('Admin Firstname');
43         $admin->setLastname('Admin Lastname');
44         $admin->setRoles(array('ROLE_SUPER_ADMIN'));
45         $userManager->updateUser($admin);
46 
47         // add test user 1
48         $test1 = $userManager->createUser();
49         $test1->setUsername('test1');
50         $test1->setEmail('test1@songbird.app');
51         $test1->setPlainPassword('test1');
52         $userManager->updatePassword($test1);
53         $test1->setEnabled(1);
54         $test1->setFirstname('test1 Firstname');
55         $test1->setLastname('test1 Lastname');
56         $userManager->updateUser($test1);
57 
58         // add test user 2
59         $test2 = $userManager->createUser();
60         $test2->setUsername('test2');
61         $test2->setEmail('test2@songbird.app');
62         $test2->setPlainPassword('test2');
63         $userManager->updatePassword($test2);
64         $test2->setEnabled(1);
65         $test2->setFirstname('test2 Firstname');
66         $test2->setLastname('test2 Lastname');
67         $userManager->updateUser($test2);
68 
69         // add test user 3
70         $test3 = $userManager->createUser();
71         $test3->setUsername('test3');
72         $test3->setEmail('test3@songbird.app');
73         $test3->setPlainPassword('test3');
74         $userManager->updatePassword($test3);
75         $test3->setEnabled(0);
76         $test3->setFirstname('test3 Firstname');
77         $test3->setLastname('test3 Lastname');
78         $userManager->updateUser($test3);
79 
80         // use this reference in data fixtures elsewhere
81         $this->addReference('admin_user', $admin);
82     }
83 
84     /**
85      * {@inheritDoc}
86      */
87     public function getOrder()
88     {
89         // load user data
90         return 1;
91     }
92 }

Now, let us insert the fixtures by running the command line

1 -> ./scripts/console doctrine:fixtures:load -n

The “-n” option simply answer yes when prompted for data purging. Try it without the “-n” option for yourself. Verify that the data is inserted by running a simple query

1 -> ./scripts/mysql "select * from user"

The nice thing about creating fixtures is that you learn a lot about the Entity when you insert the data. Take the FOSUserBundle for example, you need to know about the userManager in order to create encrypted passwords correctly. This knowledge is valuable when writing test cases.

This line shows the power of a modern day framework:

1 $userManager = $this->container->get('fos_user.user_manager');

We are trying to use the userManager class using the fos_user.user_manager service. Where is this class?

 1 -> ./scripts/console debug:container | grep fos_user.user_manager
 2  fos_user.user_manager                            FOS\UserBundle\Doctrine\UserMa\
 3 nager
 4  
 5  # you can know a great deal about this service from
 6 -> ./scripts/console debug:container fos_user.user_manager
 7 
 8 Information for Service "fos_user.user_manager"
 9 ===============================================
10 
11  ------------------ ------------------------------------- 
12   Option             Value                                
13  ------------------ ------------------------------------- 
14   Service ID         fos_user.user_manager                
15   Class              FOS\UserBundle\Doctrine\UserManager  
16   Tags               -                                    
17   Public             yes                                  
18   Synthetic          no                                   
19   Lazy               yes                                  
20   Shared             yes                                  
21   Abstract           no                                   
22   Autowired          no                                   
23   Autowiring Types   -                                    
24  ------------------ ------------------------------------- 

So basically, we are instantiating FOSUserBundleDoctrineUserManager without including the class and we do it as and when we want it. This is called Lazy Loading. Traditionally, we would require the class and use the “new” keyword, something like this:

1  require Class.php
2  $myClass = new Class();

Remember we talked about services in the previous chapter? We will see a lot more of these in the later chapters.

Doctrine Migrations

Doctrine migrations allow us to migrate db changes easily. This is important especially when we want to make changes to production db. For example, if production db is a few versions behind, do we upgrade the db sequentially and safely?

Let us start the installation:

1 -> ./scripts/composer require doctrine/doctrine-migrations-bundle "^1.0"

and update AppKernel

1 # symfony/app/AppKernel.php
2 
3 public function registerBundles()
4 {
5     $bundles = array(
6         //...
7         new Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle(),
8     );
9 }

We also need to configure it.

1 # app/config/config.yml
2 doctrine_migrations:
3     dir_name: "%kernel.root_dir%/../src/AppBundle/DoctrineMigrations"
4     namespace: AppBundle\DoctrineMigrations
5     table_name: migration_versions
6     name: AppBundle Migrations

If the installation is successful, you should see some new migrations commands added:

 1  ./scripts/console | grep migration
 2  doctrine:migrations:diff                Generate a migration by comparing your \
 3 current database to your mapping information.
 4    doctrine:migrations:execute             Execute a single migration version up\
 5  or down manually.
 6    doctrine:migrations:generate            Generate a blank migration class.
 7    doctrine:migrations:latest              Outputs the latest version number
 8    doctrine:migrations:migrate             Execute a migration to a specified ve\
 9 rsion or the latest available version.
10    doctrine:migrations:status              View the status of a set of migration\
11 s.
12    doctrine:migrations:version             Manually add and delete migration ver\
13 sions from the version table.

and

 1 -> ./scripts/console doctrine:migrations:status
 2 
 3  == Configuration
 4 
 5     >> Name:                                               AppBundle Migrations
 6     >> Database Driver:                                    pdo_mysql
 7     >> Database Name:                                      songbird
 8     >> Configuration Source:                               manually configured
 9     >> Version Table Name:                                 migration_versions
10     >> Version Column Name:                                version
11     >> Migrations Namespace:                               AppBundle\DoctrineMig\
12 rations
13     >> Migrations Directory:                               AppBundle/DoctrineMig\
14 rations
15     >> Previous Version:                                   Already at first vers\
16 ion
17     >> Current Version:                                    0
18     >> Next Version:                                       Already at latest ver\
19 sion
20     >> Latest Version:                                     0
21     >> Executed Migrations:                                0
22     >> Executed Unavailable Migrations:                    0
23     >> Available Migrations:                               0
24     >> New Migrations:                                     0

Its the first time we are using it, so we need to generate the initial migration class

1 -> ./scripts/console doctrine:migrations:generate
2    Generated new migration class to "/var/www/symfony/app/../src/AppBundle/Doctr\
3 ineMigrations/Version20170128004532.php"

Look at “Version20170128004532.php” and you won’t see much in there.

Create Script to Reset Schema and Fixtures

Every time we want to work cleanly, we want to be able to run a script to reset the database and insert dummy records. Let us create a script called resetapp that resides in scripts dir.

1 # scripts/resetapp
2 
3 #!/bin/bash
4 rm -rf var/cache/*
5 # scripts/console cache:clear --no-warmup
6 scripts/console doctrine:database:drop --force
7 scripts/console doctrine:database:create
8 scripts/console doctrine:schema:create
9 scripts/console doctrine:fixtures:load -n

Make sure the script is executable

1 -> chmod u+x ./scripts/resetapp

Now we can run the test

1 -> ./scripts/resetapp
2 ./scripts/resetapp 
3                                                             
4 ...
5   > purging database
6   > loading [1] AppBundle\DataFixtures\ORM\LoadUserData

Update runtest script

The runtest script can now call the scripts/resetapp script to have a cleaner start before running the test

1 # scripts/runtest
2 
3 #!/bin/bash
4 scripts/resetapp
5 vendor/bin/codecept run acceptance $@

What is “$@”? In bash, it means putting in the command line options that was passed into the runtest script. We can now execute only the RemovalTest like so:

 1 # remember to scripts/start_phantomjs in a new terminal if not done.
 2 
 3 -> ./scripts/runtest AppBundleCest.php:RemovalTest
 4 
 5 ...
 6 Acceptance Tests (1) 
 7 ...
 8 Time: 25.43 seconds, Memory: 11.50MB
 9 
10 OK (1 test, 1 assertion)

Summary

In this chapter we learned how to install the doctrine fixtures and migrations bundle. We also created a fixture class for our user bundle. We then upgraded our runtest script to reset the db and load the fixtures before running the test.

Remember to commit all your changes before moving on.

References

Chapter 9: The Admin Panel Part 1

We have used FOSUserBundle to create a User CRUD in the previous chapters. It’s looking ugly at the moment but its functional. However, anyone can access the user management if they have the right url. We need an admin area where administrators can login and manage the users. All administrative activities should happen behind the admin url, something along the lines of /admin/users for example.

Again, we will try to simplify the process by reusing a 3rd party module that others have created. SonataAdmin and EasyAdmin are quite popular at the moment. SonataAdmin is more advanced but more complex to setup. In this book, we will be using EasyAdmin to build the admin panel.

It wouldn’t be fun if we just use the ready made solution. In this and the next few chapters, we will attempt to build up the admin area bit by bit.

Install EasyAdminBundle

As usual, let us add the required bundles in the composer.json file

1 -> ./scripts/composer require javiereguiluz/easyadmin-bundle ^1.16.5

and remember to activate the required bundles in AppKernel.php

 1 # app/AppKernel.php
 2 ...
 3     public function registerBundles()
 4     {
 5         $bundles = array(
 6             // ...
 7             new JavierEguiluz\Bundle\EasyAdminBundle\EasyAdminBundle(),
 8         );
 9     }
10 ...

Create a new easyadmin config file

1 # app/config/easyadmin/user.yml
2 
3 easy_admin:
4     entities:
5         User:
6             class: AppBundle\Entity\User

The main config file then needs to load everything under the easyadmin folder

1 # app/config/config.yml
2 imports:
3     - { resource: parameters.yml }
4     - { resource: security.yml }
5     - { resource: services.yml }
6     - { resource: easyadmin/ }
7 ...

and routing file

1 # app/config/routing.yml
2 ...
3 easy_admin_bundle:
4     resource: "@AppBundle/Controller/AdminController.php"
5     type:     annotation
6     prefix:   /admin

If everything goes well, there will be new routes added

1 -> ./scripts/console debug:router | grep admin
2    easyadmin                        ANY        ANY      ANY    /admin/
3    admin                            ANY        ANY      ANY    /admin/

We will install the default styles from the bundle

1 -> ./scripts/console assets:install --symlink

Say for now, we want ROLE_USER to access the admin dashboard.

1 # app/config/security.yml
2 ...
3     access_control:
4         - { path: ^/login$, role: IS_AUTHENTICATED_ANONYMOUSLY }
5         # We do not allow user registration
6         # - { path: ^/register, role: IS_AUTHENTICATED_ANONYMOUSLY }
7         - { path: ^/resetting, role: IS_AUTHENTICATED_ANONYMOUSLY }
8         - { path: ^/admin/, role: ROLE_USER }

Let us create the new admin controller

 1 # src/AppBundle/Controller/AdminController.php
 2 namespace AppBundle\Controller;
 3 
 4 use JavierEguiluz\Bundle\EasyAdminBundle\Controller\AdminController as BaseAdmin\
 5 Controller;
 6 
 7 class AdminController extends BaseAdminController
 8 {
 9     public function createNewUserEntity()
10     {
11         return $this->get('fos_user.user_manager')->createUser();
12     }
13 
14     public function prePersistUserEntity($user)
15     {
16         $this->get('fos_user.user_manager')->updateUser($user, false);
17     }
18 
19     public function preUpdateUserEntity($user)
20     {
21         $this->get('fos_user.user_manager')->updateUser($user, false);
22     }
23 }

Now, try logging in

1 http://songbird.app:8000/app_dev.php/admin

By default, the admin page requires ROLE_ADMIN and above (see app/config/security.yml). So let us login as the administrator

1 username: admin
2 password: admin

wow, we can now see the admin dashboard. If you have accidentally deleted or modified the admin user, remember that you can reset the db with scripts/resetapp.

Looks pretty empty huh?

Services

services.yml is important because that is where we define reusable components. Let us create a dummy one for now.

1 # src/AppBundle/Resources/config/services.yml
2 
3 services:
4     # note that this name is important. Its how we reference the class throughou\
5 t the site.

Next, we need to create the a yml service extension and the configuration class so that the framework can load it during the bootstrap.

1 -> mkdir -p src/AppBundle/DependencyInjection
2 -> touch src/AppBundle/DependencyInjection/AppExtension.php

and AppExtensions.php contains

 1 # src/AppBundle/DependencyInjection/AppExtension.php
 2 
 3 namespace AppBundle\DependencyInjection;
 4 
 5 use Symfony\Component\DependencyInjection\ContainerBuilder;
 6 use Symfony\Component\Config\FileLocator;
 7 use Symfony\Component\HttpKernel\DependencyInjection\Extension;
 8 use Symfony\Component\DependencyInjection\Loader\YamlFileLoader;
 9 
10 /**
11 * This is the class that loads and manages your bundle configuration
12 *
13 * To learn more see {@link http://symfony.com/doc/current/cookbook/bundles/exten\
14 sion.html}
15 */
16 class AppExtension extends Extension
17 {
18     /**
19      * {@inheritdoc}
20      */
21     public function load(array $configs, ContainerBuilder $container)
22     {
23         $configuration = new Configuration();
24         $config = $this->processConfiguration($configuration, $configs);
25 
26         $loader = new YamlFileLoader($container, new FileLocator(__DIR__ . '/../\
27 Resources/config'));
28         $loader->load('services.yml');
29     }
30 }

now the configuration file

 1 # src/AppBundle/DependencyInjection/Configuration.php
 2 
 3 namespace AppBundle\DependencyInjection;
 4 
 5 use Symfony\Component\Config\Definition\Builder\TreeBuilder;
 6 use Symfony\Component\Config\Definition\ConfigurationInterface;
 7 
 8 /**
 9  * This is the class that validates and merges configuration from your app/confi\
10 g files
11  *
12  */
13 class Configuration implements ConfigurationInterface
14 {
15     /**
16      * {@inheritdoc}
17      */
18     public function getConfigTreeBuilder()
19     {
20         $treeBuilder = new TreeBuilder();
21         $treeBuilder->root('app');
22 
23         // Here you should define the parameters that are allowed to
24         // configure your bundle. See the documentation linked above for
25         // more information on that topic.
26 
27         return $treeBuilder;
28     }
29 }

Filtering the User fields

The user table has many fields. Remember that you specified what fields you want to display in src/AppBundle/Form/UserType.php? By using EasyAdmin, the creation of forms is now managed by the config. It should be self explanatory. Let us modify the fields.

 1 # app/config/easyadmin/user.yml
 2 
 3 easy_admin:
 4     entities:
 5         User:
 6             class: AppBundle\Entity\User
 7             label: 'User Management'
 8             # for new user
 9             new:
10                 fields:
11                   - username
12                   - firstname
13                   - lastname
14                   - { property: 'plainPassword', type: 'repeated', type_options:\
15  { type: 'Symfony\Component\Form\Extension\Core\Type\PasswordType', first_option\
16 s: {label: 'Password'}, second_options: {label: 'Repeat Password'}, invalid_mess\
17 age: 'The password fields must match.'}}
18                   - { property: 'email', type: 'email', type_options: { trim: tr\
19 ue } }
20                   - roles
21                   - enabled
22             edit:
23                   actions: ['-delete', '-list']
24                   fields:
25                     - username
26                     - firstname
27                     - lastname
28                     - { property: 'plainPassword', type: 'repeated', type_option\
29 s: { type: 'Symfony\Component\Form\Extension\Core\Type\PasswordType', required: \
30 false, first_options: {label: 'Password'}, second_options: {label: 'Repeat Passw\
31 ord'}, invalid_message: 'The password fields must match.'}}
32                     - { property: 'email', type: 'email', type_options: { trim: \
33 true } }
34                     - roles
35                     - enabled
36             show:
37                   actions: ['edit', '-delete', '-list']
38                   fields:
39                     - id
40                     - username
41                     - firstname
42                     - lastname
43                     - email
44                     - roles
45                     - enabled
46                     - { property: 'last_login', type: 'datetime' }
47                     - modified
48                     - created
49             list:
50                 title: 'User Listing'
51                 actions: ['show']
52                 fields:
53                   - id
54                   - username
55                   - email
56                   - firstname
57                   - lastname
58                   - enabled
59                   - roles
60                   - { property: 'last_login', type: 'datetime' }

Thanks to easyadmin, we have just created CRUD with this yaml file. We have trimmed down all the fields to include only the relevant ones. Note the plainPassword field - We have created 2 password fields with just a simple configuration.

Navigate the site and make sure they are looking good. Looking at mysql, you can see that the password has also been encrypted correctly, indicating that the AdminController’s preUpdate function is working.

Redirecting Users to Dashboard After Login

Easy.

 1 # app/config/security.yml
 2 ...
 3     firewalls:
 4         main:
 5             pattern: ^/
 6             form_login:
 7                 provider: fos_userbundle
 8                 csrf_provider: security.csrf.token_manager
 9                 default_target_path: /admin
10                 ...

User Roles and Security

What if we want ROLE_USER to login to /admin but restrict them to certain areas only?

We need to subscribe to some events so that we can add some rules based on user’s role. Remember the services.yml? It will save the day.

 1 # src/AppBundle/Resources/services.yml
 2 
 3 services:
 4 
 5     app.subscriber:
 6             class: AppBundle\EventListener\AppSubscriber
 7             arguments:
 8                 - "@service_container"
 9             tags:
10                 - { name: kernel.event_subscriber }

Let’s now create the AppSubscriber class

 1 # src/AppBundle/EventListener/AppSubscriber.php
 2 
 3 namespace AppBundle\EventListener;
 4 
 5 use Symfony\Component\EventDispatcher\EventSubscriberInterface;
 6 use Symfony\Component\EventDispatcher\GenericEvent;
 7 use Symfony\Component\Security\Core\Exception\AccessDeniedException;
 8 use JavierEguiluz\Bundle\EasyAdminBundle\Event\EasyAdminEvents;
 9 use Symfony\Component\DependencyInjection\ContainerInterface;
10 
11 class AppSubscriber implements EventSubscriberInterface
12 {
13     protected $container;
14 
15     /**
16      * AppSubscriber constructor.
17      * @param ContainerInterface $container
18      */
19     public function __construct(ContainerInterface $container) // this is @servi\
20 ce_container
21     {
22         $this->container = $container;
23     }
24 
25     /**
26      * @return array
27      */
28     public static function getSubscribedEvents()
29     {
30         // return the subscribed events, their methods and priorities
31         return array(
32             EasyAdminEvents::PRE_LIST => 'checkUserRights',
33             EasyAdminEvents::PRE_EDIT => 'checkUserRights',
34             EasyAdminEvents::PRE_SHOW => 'checkUserRights',
35             EasyAdminEvents::PRE_NEW => 'checkUserRights',
36             EasyAdminEvents::PRE_DELETE => 'checkUserRights'
37         );
38     }
39 
40     /**
41      * show an error if user is not superadmin and tries to manage restricted st\
42 uff
43      *
44      * @param GenericEvent $event event
45      * @return null
46      * @throws AccessDeniedException
47      */
48     public function checkUserRights(GenericEvent $event)
49     {
50 
51         // if super admin, allow all
52         if ($this->container->get('security.authorization_checker')->isGranted('\
53 ROLE_SUPER_ADMIN')) {
54             return;
55         }
56 
57         $entity = $this->container->get('request_stack')->getCurrentRequest()->q\
58 uery->get('entity');
59         $action = $this->container->get('request_stack')->getCurrentRequest()->q\
60 uery->get('action');
61         $user_id = $this->container->get('request_stack')->getCurrentRequest()->\
62 query->get('id');
63 
64         // if user management, only allow ownself to edit and see ownself
65         if ($entity == 'User') {
66             // if edit and show
67             if ($action == 'edit' || $action == 'show') {
68                 // check user is himself
69                 if ($user_id == $this->container->get('security.token_storage')-\
70 >getToken()->getUser()->getId()) {
71                     return;
72                 }
73             }
74         }
75 
76         // throw exception in all cases
77         throw new AccessDeniedException();
78     }
79 }
80 ...

Basically, we have created a checkUserRights function to ensure that other than the super admin, only the rightful owner can edit and see his own profile only.

Try logging in as test1:test1 (user id = 2) and see own profile

1 http://songbird.app:8000/app_dev.php/admin/?action=show&entity=User&id=2

If test1 tries to see other people’s profile, we should get an access denied error.

1 http://songbird.app:8000/app_dev.php/admin/?action=show&entity=User&id=3

User list url should give us access denied as well.

1 http://songbird.app:8000/app_dev.php/admin/?action=list&entity=User

There is one more thing we need to clean up. If I login as ROLE_USER, I should not be able to see certain fields.

Under the “edit” action, I should not see the roles, enabled, locked, and expired fields.

Under the “show” action, I should not see the created field. User should also be redirected to the show page when the details are updated.

 1 # src/AppBundle/Controller/AdminController.php
 2 
 3 use JavierEguiluz\Bundle\EasyAdminBundle\Event\EasyAdminEvents;
 4 
 5     ....
 6      /**
 7       * @return \Symfony\Component\HttpFoundation\Response
 8       */
 9      public function showUserAction()
10      {
11          $this->dispatch(EasyAdminEvents::PRE_SHOW);
12          $id = $this->request->query->get('id');
13          $easyadmin = $this->request->attributes->get('easyadmin');
14          $entity = $easyadmin['item'];
15  
16          $fields = $this->entity['show']['fields'];
17  
18          if (!$this->isGranted('ROLE_SUPER_ADMIN')) {
19              unset($fields['created']);
20          }
21  
22          $deleteForm = $this->createDeleteForm($this->entity['name'], $id);
23  
24          return $this->render($this->entity['templates']['show'], array(
25              'entity' => $entity,
26              'fields' => $fields,
27              'delete_form' => $deleteForm->createView(),
28          ));
29      }
30          
31      /**
32       * when edit user action
33       * 
34       * @return Response|\Symfony\Component\HttpFoundation\RedirectResponse|\Sym\
35 fony\Component\HttpFoundation\Response
36       */
37      protected function editUserAction()
38      {
39          $this->dispatch(EasyAdminEvents::PRE_EDIT);
40          $id = $this->request->query->get('id');
41          $easyadmin = $this->request->attributes->get('easyadmin');
42          $entity = $easyadmin['item'];
43  
44          if ($this->request->isXmlHttpRequest() && $property = $this->request->q\
45 uery->get('property')) {
46              $newValue = 'true' === strtolower($this->request->query->get('newVa\
47 lue'));
48              $fieldsMetadata = $this->entity['list']['fields'];
49  
50              if (!isset($fieldsMetadata[$property]) || 'toggle' !== $fieldsMetad\
51 ata[$property]['dataType']) {
52                  throw new \RuntimeException(sprintf('The type of the "%s" prope\
53 rty is not "toggle".', $property));
54              }
55  
56              $this->updateEntityProperty($entity, $property, $newValue);
57  
58              return new Response((string)$newValue);
59          }
60  
61          $fields = $this->entity['edit']['fields'];
62  
63          $editForm = $this->createEditForm($entity, $fields);
64          if (!$this->isGranted('ROLE_SUPER_ADMIN')) {
65              $editForm->remove('enabled');
66              $editForm->remove('roles');
67          }
68  
69          $deleteForm = $this->createDeleteForm($this->entity['name'], $id);
70  
71          $editForm->handleRequest($this->request);
72          if ($editForm->isValid()) {
73              $this->preUpdateUserEntity($entity);
74              $this->em->flush();
75  
76              $refererUrl = $this->request->query->get('referer', '');
77  
78              return !empty($refererUrl)
79                  ? $this->redirect(urldecode($refererUrl))
80                  : $this->redirect($this->generateUrl('easyadmin', array('action\
81 ' => 'show', 'entity' => $this->entity['name'], 'id' => $id)));
82          }
83  
84          return $this->render($this->entity['templates']['edit'], array(
85              'form' => $editForm->createView(),
86              'entity_fields' => $fields,
87              'entity' => $entity,
88              'delete_form' => $deleteForm->createView(),
89          ));
90      }

Easyadmin allows creation of isolated entity functions like “editUserAction”. This is brilliant because updating this function won’t affect other entities.

Cleaning up

Since we are not going to use FOSUserBundle /profile url to change update user profile, let us remove it from the routing.yml

1 # app/config/routing.yml
2 
3 #fos_user_profile:
4 #    resource: "@FOSUserBundle/Resources/config/routing/profile.xml"
5 #    prefix: /profile
6 
7 #fos_user_change_password:
8 #    resource: "@FOSUserBundle/Resources/config/routing/change_password.xml"
9 #    prefix: /profile

Now let us do some cleaning up. Since we are now using EasyAdmin, a lot of files that we have generated using the command line are no longer needed. As you can see, automation is only good if you know what you are doing.

 1 git rm src/AppBundle/Controller/UserController.php
 2 git rm src/AppBundle/Form/UserType.php
 3 git rm -rf app/Resources/views/default
 4 git rm -rf src/AppBundle/Tests/Controller/UserControllerTest.php
 5 git rm -rf src/AppBundle/Tests/Controller/DefaultControllerTest.php
 6 
 7 # All efforts gone? Don't worry, we will write new tests in the next chapter
 8 git rm -rf tests
 9 git rm codeception.yml
10 # add all changes
11 git add .

Summary

We have installed a popular Admin system called EasyAdminBundle. We then integrated FOSUserBundle with EasyAdminBundle and customised some fields. We have also configured the security of the system such that unless the logged in user is a super admin, the user can only see or update his own profile.

Remember to commit your changes before moving on to the next chapter.

Exercises

  • Try installing the SonataAdminBundle yourself and see the differences in both approach.

References

Chapter 10: BDD With Codeception (Optional)

This chapter is optional, feel free to skip it if you already have your own testing framework in place.

Behavioural-Driven-Development (BDD) is best used as integration testing. It is the concept of writing tests based on user’s behaviour. The way users interact with the software defines the requirements for the software. Once we know the requirements, we are able to write tests and simulate user’s interaction with the software.

In BDD, each user’s requirement (user story) can be created using the following template:

1 As a ...
2 I (don't) want to ...
3 So that ...

We can then further breakdown the story into scenarios. For each scenario, we define the “When” (user’s action) and the “Then” (acceptance criteria).

1 Given scenario
2 When ...
3 Then ...

It is a good idea to create a matrix for user stories and test scenarios to fully capture user’s requirement as part of the functional specifications.

User Stories

Let us define the user stories for this chapter. We will define the user stories before each chapter from now on.

User Story 10: User Management

Story Id As a I So that I
10.1 test1 user want to login can access admin functions
10.2 admin user want to login can access admin functions
10.3 test3 user don’t want to login can prove that this account is disabled
10.4 test1 user want to manage my own profile can update it any time
10.5 test1 user dont’t want to manage other profiles don’t breach security
10.6 admin user want to manage all users can control user access of the system

User Scenarios

We will break the individual story down with user scenarios.

Story ID 10.1: As a test1 user, I want to login, so that I can access admin functions.

Scenario Id Given When Then
10.1.1 Wrong login credentials I login with the wrong credentials I should see an error message
10.1.2 See my dashboard content I login correctly I should see Access Denied
10.1.3 Logout successfully I go to the logout url I should be redirected to the home page
10.1.4 Access admin url without logging in go to admin url without logging in I should be redirected to the login page

Story ID 10.2: As a admin user, I want to login, so that I can access admin functions.

Scenario Id Given When Then
10.2.1 Wrong login credentials I login with the wrong credentials I should see an error message
10.2.2 See my dashboard content I login correctly I should see the text User Management
10.2.3 Logout successfully go to the logout url I should be redirected to the home page
10.2.4 Access admin url without logging in go to admin url without logging in I should be redirected to the login page

Story ID 10.3: As a test3 user, I don’t want to login successfully, so that I can prove that this account is disabled.

Scenario Id Given When Then
10.3.1 Account disabled I login with the right credentials I should see an “account disabled” message

Story ID 10.4: As a test1 user, I want to manage my profile, so that I can update it any time.

Scenario Id Given** When Then**
10.4.1 Show my profile I go to “/admin/?action=show&entity=User&id=2” I should see test1@songbird.app
10.4.2 Hid uneditable fields I go to “/admin/?action=edit&entity=User&id=2” I should not see enabled and roles fields
10.4.3 Update Firstname Only I go to “/admin/?action=edit&entity=User&id=2” And update firstname only And Submit I should see content updated
10.4.4 Update Password Only I go to “/admin/?action=edit&entity=User&id=2” And update password And Submit And Logout And Login Again I should see content updated And be able to login with the new password

Story ID 10.5: As a test1 user, I don’t want to manage other profiles, so that I don’t breach security.

Scenario Id Given When Then
10.5.1 List all profiles I go to “/admin/?action=list&entity=User” url I should get an “access denied” error.
10.5.2 Show test2 profile I go to “/admin/?action=show&entity=User&id=3” I should get an “access denied” error.
10.5.3 Edit test2 user profile I go to “/admin/?action=edit&entity=User&id=3” I should get an “access denied” error
10.5.4 See admin dashboard content I login correctly I should not see User Management Text

Story ID 10.6: As an admin user, I want to manage all users, so that I can control user access of the system.

Scenario Id Given When **Then
10.6.1 List all profiles I go to “/admin/?action=list&entity=User” url I should see a list of all users in a table
10.6.2 Show test3 user I go to “/admin/?action=show&entity=User&id=4” url I should see test3 user details
10.6.3 Edit test3 user I go to “/admin/?action=edit&entity=User&id=4” url And update lastname I should see test3 lastname updated on the “List all users” page
10.6.4 Create and Delete new user I go to “/admin/?action=new&entity=User” And fill in the required fields And Submit And Delete the new user I should see the new user created and deleted again in the listing page.

Creating the Cest Class

Since we have already deleted the test directory, let us create the testing framework under src/AppBundle

1 # in symfony dir
2 -> vendor/bin/codecept bootstrap src/AppBundle
3 -> vendor/bin/codecept build -c src/AppBundle

and update the acceptance file again

 1 # src/AppBundle/tests/acceptance.suite.yml
 2 class_name: AcceptanceTester
 3 modules:
 4     enabled:
 5         - WebDriver:
 6             url: 'http://songbird.app'
 7             host: 172.25.0.5
 8             port: 4444
 9             browser: phantomjs
10             window_size: 1024x768
11             capabilities:
12                 unexpectedAlertBehaviour: 'accept'
13                 webStorageEnabled: true
14         - \Helper\Acceptance

Codeception is really flexible in the way we create the test scenarios. Take User Story 1 for example, we can break the user story down into directories and scenarios into cest class. Let us create the files:

 1 -> vendor/bin/codecept generate:cest acceptance As_Test1_User/IWantToLogin -c sr\
 2 c/AppBundle
 3 -> vendor/bin/codecept generate:cest acceptance As_An_Admin/IWantToLogin -c src/\
 4 AppBundle
 5 -> vendor/bin/codecept generate:cest acceptance As_Test3_User/IDontWantTologin -\
 6 c src/AppBundle
 7 -> vendor/bin/codecept generate:cest acceptance As_Test1_User/IWantToManageMyOwn\
 8 Profile -c src/AppBundle
 9 -> vendor/bin/codecept generate:cest acceptance As_Test1_User/IDontWantToManageO\
10 therProfiles -c src/AppBundle
11 -> vendor/bin/codecept generate:cest acceptance As_An_Admin/IWantToManageAllUser\
12 s -c src/AppBundle

We will create a common class in the bootstrap and define all the constants we need for the test.

 1 # src/AppBundle/tests/acceptance/_bootstrap.php
 2 
 3 define('ADMIN_USERNAME', 'admin');
 4 define('ADMIN_PASSWORD', 'admin');
 5 define('TEST1_USERNAME', 'test1');
 6 define('TEST1_PASSWORD', 'test1');
 7 define('TEST2_USERNAME', 'test2');
 8 define('TEST2_PASSWORD', 'test2');
 9 // test3 Account is disabled. See data fixtures to confirm.
10 define('TEST3_USERNAME', 'test3');
11 define('TEST3_PASSWORD', 'test3');
12 
13 
14 class Common
15 {
16 	public static function login(AcceptanceTester $I, $user, $pass)
17     {
18         $I->amOnPage('/login');
19         $I->fillField('_username', $user);
20         $I->fillField('_password', $pass);
21         $I->click('_submit');
22     }
23 }

Let us try creating story 10.6

 1 # src/AppBundle/tests/acceptance/As_An_Admin/IWantToManageAllUsersCest.php
 2 
 3 namespace As_An_Admin;
 4 use \AcceptanceTester;
 5 use \Common;
 6 
 7 class IWantToManageAllUsersCest
 8 {
 9     public function _before(AcceptanceTester $I)
10     {
11     }
12 
13     public function _after(AcceptanceTester $I)
14     {
15     }
16 
17     protected function login(AcceptanceTester $I)
18     {
19         Common::login($I, ADMIN_USERNAME, ADMIN_PASSWORD);
20     }
21 
22     /**
23      * Scenario 10.6.1
24      * @before login
25      */
26     public function listAllProfiles(AcceptanceTester $I)
27     {
28         $I->amOnPage('/admin/?action=list&entity=User');
29         // the magic of xpath
30         $I->canSeeNumberOfElements('//table/tbody/tr',4);
31     }
32 }

Noticed the xpath selector?

1 //table/tbody/tr

This is the xpath for the show button. How do we know where it is located? We can inspect the elements with the developer tool (available in many browser).

You also noticed that the login class is protected rather than public. Protected class won’t be executed when we run the “runtest” command but we can use it as a pre-requisite when testing listAppProfiles scenario for example, ie the @before login annotation.

listAllProfiles function goes to the user listing page and checks for 4 rows in the table. How do I know about the amOnPage and canSeeNumberOfElements functions? Remembered you ran the command “/bin/codecept build” before? This command generates the AcceptanceTester class to be used in the Cest class. All the functions of the AcceptanceTester class can be found in the “src/AppBundle/Tests/_support/_generated/AcceptanceTesterActions.php” class.

In the test, I used the user listing url directly rather than clicking on the “User Management” link. We should be simulating user clicking on the “User Management” link instead. We will update the test again once we work on the updated UI.

Let us update the runtest script

1 # symfony/scripts/runtest
2 
3 #!/bin/bash
4 
5 scripts/resetapp
6 docker-compose exec php vendor/bin/codecept run acceptance $@ -c src/AppBundle

and update the gitignore path

1 # symfony/.gitignore
2 ...
3 src/AppBundle/tests/_output/*

Then, run the test only for scenario 10.6.1

1 -> ./scripts/runtest As_An_Admin/IWantToManageAllUsersCest.php:listAllProfiles
2 ...
3 OK (1 test, 1 assertion)

Looking good, what if the test fails and you want to look at the logs? The log files are all in the “src/AppBundle/tests/_output/” directory.

Let us write another test for scenario 10.6.2. We will simulate clicking on test3 show button and check the page is loading fine.

 1 # src/AppBundle/tests/acceptance/As_An_Admin/IWantToManageAllUsersCest.php
 2 ...
 3    /**
 4     * Scenario 10.6.2
 5     * @before login
 6     */
 7    public function showTest3User(AcceptanceTester $I)
 8    {
 9        // go to user listing page
10        $I->amOnPage('/admin/?action=list&entity=User');
11        // click on show button
12        $I->click('Show');
13        $I->waitForText('test3@songbird.app');
14        $I->canSee('test3@songbird.app');
15    }
16 ...

run the test now

1 -> ./scripts/runtest As_An_Admin/IWantToManageAllUsersCest.php:showTest3User

and you should get a success message.

We will now write the test for scenario 10.6.3

 1 # src/AppBundle/tests/acceptance/As_An_Admin/IWantToManageAllUsersCest.php
 2 ...
 3     /**
 4      * Scenario 10.6.3
 5      * @before login
 6      */
 7     public function editTest3User(AcceptanceTester $I)
 8     {
 9         // go to user listing page
10         $I->amOnPage('/admin/?action=list&entity=User');
11         // click on edit button
12         $I->click('Edit');
13         // check we are on the right url
14         $I->canSeeInCurrentUrl('/admin/?action=edit&entity=User');
15         $I->fillField('//input[@value="test3 Lastname"]', 'lastname3 updated');
16         // update
17         $I->click('//button[@type="submit"]');
18         // go back to listing page
19         $I->amOnPage('/admin/?action=list&entity=User');
20         $I->canSee('lastname3 updated');
21         // now revert username
22         $I->amOnPage('/admin/?action=edit&entity=User&id=4');
23         $I->fillField('//input[@value="lastname3 updated"]', 'test3 Lastname');
24         $I->click('//button[@type="submit"]');
25         $I->amOnPage('/admin/?action=list&entity=User');
26         $I->canSee('test3 Lastname');
27     }
28 ...

Run the test now to make sure everything is ok before moving on.

1 -> ./scripts/runtest As_An_Admin/IWantToManageAllUsersCest.php:editTest3User

and scenario 10.6.4

 1 # src/AppBundle/tests/acceptance/As_An_Admin/IWantToManageAllUsersCest.php
 2 ...
 3    /**
 4     * Scenario 10.6.4
 5     * @before login
 6     */
 7    public function createAndDeleteNewUser(AcceptanceTester $I)
 8    {
 9        // go to create page and fill in form
10        $I->amOnPage('/admin/?action=new&entity=User');
11        $I->fillField('//input[contains(@id, "_username")]', 'test4');
12        $I->fillField('//input[contains(@id, "_email")]', 'test4@songbird.app');
13        $I->fillField('//input[contains(@id, "_plainPassword_first")]', 'test4');
14        $I->fillField('//input[contains(@id, "_plainPassword_second")]', 'test4');
15        // submit form
16        $I->click('//button[@type="submit"]');
17        // go back to user list
18        $I->amOnPage('/admin/?entity=User&action=list');
19        // i should see new test4 user created
20        $I->canSee('test4@songbird.app');
21 
22        // now delete user
23        // click on edit button
24        $I->click('Delete');
25        // wait for model box and then click on delete button
26        $I->waitForElementVisible('//button[@id="modal-delete-button"]');
27        $I->click('//button[@id="modal-delete-button"]');
28        // I can no longer see test4 user
29        $I->cantSee('test4@songbird.app');
30    }
31 ...

createNewUser test is a bit longer. I hope the comments are self explainatory.

Let’s run the test just for this scenario.

1 -> ./scripts/runtest As_An_Admin/IWantToManageAllUsersCest.php:createAndDeleteNe\
2 wUser

Feeling confident? We can run all the test together.

 1 -> ./scripts/runtest
 2 
 3 Dropped database for connection named `songbird`
 4 Created database `songbird` for connection named default
 5 ATTENTION: This operation should not be executed in a production environment.
 6 
 7 Creating database schema...
 8 Database schema created successfully!
 9   > purging database
10   > loading [1] AppBundle\DataFixtures\ORM\LoadUserData
11 Codeception PHP Testing Framework v2.2.8
12 Powered by PHPUnit 5.7.5 by Sebastian Bergmann and contributors.
13 
14 Acceptance Tests (9) ------------------------------------------
15 Testing acceptance
16 ✔ IWantToLoginCest: Try to test (0.00s)
17 ✔ IWantToManageAllUsersCest: List all profiles (26.24s)
18 ✔ IWantToManageAllUsersCest: Show test3 user (16.23s)
19 ✔ IWantToManageAllUsersCest: Edit test3 user (37.66s)
20 ✔ IWantToManageAllUsersCest: Create and delete new user (30.73s)
21 ✔ IDontWantToManageOtherProfilesCest: Try to test (0.00s)
22 ✔ IWantToLoginCest: Try to test (0.00s)
23 ✔ IWantToManageMyOwnProfileCest: Try to test (0.00s)
24 ✔ IDontWantTologinCest: Try to test (0.00s)
25 ----------------------------------------------------------------
26 
27 
28 Time: 1.86 minutes, Memory: 15.00MB
29 
30 OK (9 tests, 7 assertions)

Want more detail output? Try this

1 -> ./scripts/runtest --steps

How about with debug mode

1 -> ./scripts/runtest -d

Tip: If you are using mac and got “too many open files” error, you need to change the ulimit to something bigger

1 -> ulimit -n 2048

Add this to your ~/.bash_profile if you want to change the limit everytime you open up a shell.

If your machine is slow, sometimes it might take too long before certain text or element is being detected. In that case, use the “waitForxxx” function before the assert statement, like so

1 # wait for element to be loaded first
2 # you can see all the available functions in src/AppBundle/Tests/_support/_gener\
3 ated/AcceptanceTesterActions.php
4 $I->waitForElement('//div[contains(@class, "alert-success")]');
5 # now we can do the assert statement
6 $I->canSeeElement('//div[contains(@class, "alert-success")]');

We have only written the BDD tests for user story 10.6. Are you ready to write acceptance tests for the other user stories?

Writing tests can be a boring process but essential if you want your software to be robust. A tip to note is that every scenario must have a closure so that it is self-contained. The idea is that you can run a test scenario by itself without affecting the rest of the scenarios. For example, if you change a password in a scenario, you have to remember to change it back so that you can run the next test without worrying that the password being changed. Alternatively, you could reset the db after every test but this could make running all the tests longer. There are also other ways to achieve this. How could you do it so that it doesn’t affect performance?

The workflow in this book is just one of many ways to write BDD tests. It is worth knowing that at the time of writing, many people uses behat.

Summary

In this chapter, we wrote our own CEST classes based on different user stories and scenarios. We are now more confident that we have a way to test Songbird’s user management functionality as we add more functionalities in the future.

Remember to commit your changes before moving on the next chapter.

Exercises

  • Write acceptance test for User Stories 10.1, 10.2, 10.3, 10.4 and 10.5 and make sure all test passes.
  • (Optional) Can you think of other business rules for user management? Try adding your own CEST.

References

Chapter 11: Customising the Login Process

In the previous chapters, we have created the admin area and wrote tests for managing users in the admin area. The login page and admin area is still looking plain at the moment. There are still lots to do but let us take a break from the backend logic and look at frontend templating. Symfony is using twig as the default templating engine. If you new to twig, have a look at twig website. In this chapter, we will touch up the login interface.

Defining User Stories and Scenarios

11. Reset Password

Story Id As a I So that I
11.1 test1 user want to reset my password without logging in have a way to access my account in case I forget or loses my password.

Story ID 11.1: As a test1 user, I want to be able to reset my password without logging in, so that I have a way to access my account in case I forget or loses my password.

Scenario Id Given When Then
11.1.1 Reset Password Successfully I click on forget password in the login page and go through the whole resetting process I should be redirected to the dashboard.

Customise the Login Page

I have installed twitter bootstrap in the public dir and created a simple logo for Songbird. You can get all the files by checking out from chapter_11 repo. My Resources dir looks like this:

 1 # src/AppBundle/Resource
 2 
 3 public/
 4 ├── css
 5 │   ├── bootstrap-theme.css
 6 │   ├── bootstrap-theme.css.map
 7 │   ├── bootstrap-theme.min.css
 8 │   ├── bootstrap-theme.min.css.map
 9 │   ├── bootstrap.css
10 │   ├── bootstrap.css.map
11 │   ├── bootstrap.min.css
12 │   ├── bootstrap.min.css.map
13 │   └── signin.css
14 ├── fonts
15 │   ├── glyphicons-halflings-regular.eot
16 │   ├── glyphicons-halflings-regular.svg
17 │   ├── glyphicons-halflings-regular.ttf
18 │   ├── glyphicons-halflings-regular.woff
19 │   └── glyphicons-halflings-regular.woff2
20 ├── images
21 │   └── logo.png
22 └── js
23     ├── bootstrap.js
24     ├── bootstrap.min.js
25     ├── jquery.min.js
26     └── npm.js

Let us create our own base layout. The idea is to extend this layout for all twig files that we create in the future.

1 # clean up old files
2 -> git rm -rf app/Resources/views/user

and base.html.twig

 1 # app/Resources/views/base.html.twig
 2 
 3 <!DOCTYPE HTML>
 4 <html lang="en-US">
 5 <head>
 6     <meta charset="utf-8">
 7     <meta http-equiv="X-UA-Compatible" content="IE=edge">
 8     <meta name="viewport" content="width=device-width, initial-scale=1">
 9 	<title>{% block title %}{% endblock %}</title>
10 	{% block stylesheets %}
11 	   <link href="{{ asset('bundles/app/css/bootstrap.min.css') }}" rel="styleshee\
12 t" />
13     {% endblock %}
14 </head>
15 <body>
16 {% block body %}
17 
18     {% block content %}{% endblock %}
19 
20 {% endblock %}
21 
22 {% block script %}
23     <script src="{{ asset('bundles/app/js/jquery.min.js') }}"></script>
24     <script src="{{ asset('bundles/app/js/bootstrap.min.js') }}"></script>
25 {% endblock %}
26 
27 </body>
28 </html>

To override FOSUserBundle template, have a look at the vendors/friendsofsymfony/Resources/views. Thanks to inheritance, we can override login.html.twig based on the layout that we have created.

Let us create the login file

1 -> mkdir -p app/Resources/FOSUserBundle/views/Security
2 -> touch app/Resources/FOSUserBundle/views/Security/login.html.twig

Now the actual login file:

 1 # app/Resources/FOSUserBundle/views/Security/login.html.twig
 2 
 3 {% extends "base.html.twig" %}
 4 
 5 {% block title %}
 6     {{ 'layout.login'|trans({}, 'FOSUserBundle') }}
 7 {% endblock %}
 8 
 9 {% block stylesheets %}
10     {{ parent() }}
11     <link href="{{ asset('bundles/app/css/signin.css') }}" rel="stylesheet" />
12 {% endblock %}
13 
14 {% trans_default_domain 'FOSUserBundle' %}
15 
16 {% block content %}
17 
18 <img src="{{ asset('bundles/app/images/logo.png') }}" class="center-block img-re\
19 sponsive" alt="Songbird" />
20 
21 <div class="container">
22     <div class="text-center">
23         {% if is_granted("IS_AUTHENTICATED_REMEMBERED") %}
24             {{ 'layout.logged_in_as'|trans({'%username%': app.user.username}, 'F\
25 OSUserBundle') }} |
26             <a href="{{ path('fos_user_security_logout') }}">
27                 {{ 'layout.logout'|trans({}, 'FOSUserBundle') }}
28             </a>
29         {% endif %}
30         <form class="form-signin" action="{{ path("fos_user_security_check") }}"\
31  method="post">
32             {% if error %}
33                 <div class="alert alert-danger">{{ error.messageKey|trans(error.\
34 messageData, 'security') }}</div>
35             {% endif %}
36 
37             <input type="hidden" name="_csrf_token" value="{{ csrf_token }}" />
38 
39             <input type="text" id="username" name="_username" class="form-contro\
40 l" value="{{ last_username }}" required="required" placeholder="{{ 'security.log\
41 in.username'|trans }}" />
42 
43             <input type="password" id="password" name="_password" class="form-co\
44 ntrol" required="required" placeholder="{{ 'security.login.password'|trans }}" /\
45 >
46 
47             <div class="checkbox">
48               <label>
49                     <input type="checkbox" id="remember_me" name="_remember_me" \
50 value="on" />{{ 'security.login.remember_me'|trans }}
51               </label>
52             </div>
53 
54             <input class="btn btn-lg btn-primary btn-block" type="submit" id="_s\
55 ubmit" name="_submit" value="{{ 'security.login.submit'|trans }}" />
56         </form>
57     </div>
58 </div>
59 {% endblock %}

Once we are done, we can get the assets over to the web dir.

1 -> ./scripts/console assets:install --symlink

This command basically soft linked everything under the public dir of all bundles over to the document root dir (/web).

Let us go to http://songbird.app:8000/app_dev.php/login and look at our login page now

Installing Mailcatcher

Mailcatcher is excellent for debugging and testing email related functionality. An excellent use case is to test the “forget password” feature.

When using docker, installing a new service is easy (don’t need to worry about dependencies). We just need to get a pre-made mailcatcher image and off we go.

 1 # docker-compose.yml
 2 ...
 3     mailcatcher:
 4         image: yappabe/mailcatcher
 5         ports:
 6           - 1025:1025
 7           - 1080:1080
 8         networks:
 9             mynet:
10                 ipv4_address: 172.25.0.6

Now let us fire up the new container

1 # under songbird dir
2 -> docker-compose down
3 -> docker-compose up -d

We also need to make sure swiftmailer is configured to talk to the new mailcatcher host

 1 # symfony/app/config/parameters.yml
 2 # configure based on your own settings
 3 
 4 parameters:
 5     database_host: 172.18.0.2
 6     database_port: 3306
 7     database_name: songbird
 8     database_user: root
 9     database_password: root
10     mailer_transport: smtp
11     
12     # port 1025 is the mailcatcher sending port    
13     mailer_host: '172.25.0.6:1025'
14     mailer_user: null
15     mailer_password: null
16     secret: mysecret

Customise the Request Password Page

A full login process should also include the password reset functionality in case the user forgets his password. Fortunately again, FOSUSerBundle has all these features built-in already. We just need to make minor tweaks to the process and customise the templates.

The password reset process is as follows:

  1. User goes to the forget password page.
  2. User enters the username or email.
  3. User gets an email a reset link.
  4. User clicks on the email and goes to a password reset page.
  5. User enters the new password and click submit.
  6. User automatically gets redirected to the dashboard.

We will put a link on the login page to the request password page. We can find all the links from the debug:router command (a command you should be familiar by now)

 1 -> ./scripts/console debug:router | grep fos
 2   fos_user_security_login          GET|POST   ANY      ANY    /login            \
 3                  
 4   fos_user_security_check          POST       ANY      ANY    /login_check      \
 5                  
 6   fos_user_security_logout         GET|POST   ANY      ANY    /logout           \
 7                  
 8   fos_user_resetting_request       GET        ANY      ANY    /resetting/request\
 9                  
10   fos_user_resetting_send_email    POST       ANY      ANY    /resetting/send-em\
11 ail              
12   fos_user_resetting_check_email   GET        ANY      ANY    /resetting/check-e\
13 mail             
14   fos_user_resetting_reset         GET|POST   ANY      ANY    /resetting/reset/{\
15 token} 

Let us add the new request password link

1 # app/Resources/FOSUserBundle/views/Security/login.html.twig
2 ...
3   <input class="btn btn-lg btn-primary btn-block" type="submit" id="_submit" nam\
4 e="_submit" value="{{ 'security.login.submit'|trans }}" />
5 
6   <div class="checkbox">
7     <a href="{{ path('fos_user_resetting_request') }}">forget password</a>
8   </div>
9 ...

By looking at vendors/friendsofsymfony/Resources/views, we can create all the required twig files to override.

1 -> cd app/Resources/FOSUserBundle/views/
2 -> mkdir Resetting
3 -> touch Resetting/checkEmail.html.twig
4 -> touch Resetting/passwordAlreadyRequested.html.twig
5 -> touch Resetting/request.html.twig
6 -> touch Resetting/reset.html.twig

Let us create the request password page based on the base.twig.html that we have created earlier.

 1 # app/Resources/FOSUserBundle/views/Resetting/request.html.twig
 2 
 3 {% extends "base.html.twig" %}
 4 
 5 {% trans_default_domain 'FOSUserBundle' %}
 6 
 7 {% block title %}
 8     {{ 'resetting.request.submit'|trans }}
 9 {% endblock %}
10 
11 {% block stylesheets %}
12     {{ parent() }}
13     <link href="{{ asset('bundles/app/css/signin.css') }}" rel="stylesheet" />
14 {% endblock %}
15 
16 {% block content %}
17 
18 <img src="{{ asset('bundles/app/images/logo.png') }}" class="center-block img-re\
19 sponsive" alt="Songbird" />
20 
21 <div class="container">
22     <div class="text-center">
23         <h3>Enter Your Username or Password</h3>
24         <form class="form-signin" action="{{ path('fos_user_resetting_send_email\
25 ') }}" method="post">
26             {% if invalid_username is defined %}
27                 <div class="alert alert-danger">{{ 'resetting.request.invalid_us\
28 ername'|trans({'%username%': app.request.get('username')}) }}</div>
29             {% endif %}
30             <input type="text" id="username" name="username" class="form-control\
31 " required="required" value="{{ app.request.get('username') }}" placeholder="{{ \
32 'resetting.request.username'|trans }}" />
33 
34             <input class="btn btn-lg btn-primary btn-block" type="submit" id="_s\
35 ubmit" name="_submit" value="{{ 'resetting.request.submit'|trans }}" />
36         </form>
37     </div>
38 </div>
39 {% endblock %}

From the login page, click on the forget password link and you should go to the request password page

Likewise we are going to customise the password request success message.

 1 # app/Resources/FOSUserBundle/views/Resetting/check_email.html.twig
 2 
 3 {% extends "base.html.twig" %}
 4 
 5 {% trans_default_domain 'FOSUserBundle' %}
 6 
 7 {% block title %}
 8     {{ 'resetting.request.submit'|trans }}
 9 {% endblock %}
10 
11 {% block stylesheets %}
12     {{ parent() }}
13     <link href="{{ asset('bundles/app/css/signin.css') }}" rel="stylesheet" />
14 {% endblock %}
15 
16 {% block content %}
17 
18     <img src="{{ asset('bundles/app/images/logo.png') }}" class="center-block im\
19 g-responsive" alt="Songbird" />
20 
21     <div class="container">
22         <h3> </h3>
23         <div class="text-center">
24             {{ 'resetting.check_email'|trans({'%tokenLifetime%': tokenLifetime})\
25  }}
26         </div>
27     </div>
28 {% endblock %}

A successful password request looks like this:

What if you request password reset more than once in a day? FOSUserBundle actually doesn’t allow you to do that.

A screenshot of the password already requested error:

When the password request email is send successfully, the user should request a link to reset the password. Our mailcatcher is configured to capture all emails fired.

Let us go to

1 http://songbird.app:1080

If you click on the link, you will go to the actual reset page to enter the new password.

Try entering a new password and see what happens. Nothing? Because we haven’t add the reset.html.twig. Let us do it now.

 1 # app/Resources/FOSUserBundle/views/Resetting/reset.html.twig
 2 
 3 {% extends "base.html.twig" %}
 4 
 5 {% trans_default_domain 'FOSUserBundle' %}
 6 
 7 {% block title %}
 8     {{ 'resetting.request.submit'|trans }}
 9 {% endblock %}
10 
11 {% block stylesheets %}
12     {{ parent() }}
13     <link href="{{ asset('bundles/app/css/signin.css') }}" rel="stylesheet" />
14 {% endblock %}
15 
16 {% block content %}
17 
18     <img src="{{ asset('bundles/app/images/logo.png') }}" class="center-block im\
19 g-responsive" alt="Songbird" />
20 
21     <div class="container">
22         <div class="text-center">
23             <h3>Enter Your New Password</h3>
24             <form class="form-signin" action="{{ path('fos_user_resetting_reset'\
25 , {'token': token}) }}" method="post">
26 
27                 {% if (app.request.get('fos_user_resetting_form')['plainPassword\
28 ']['first']) is defined and (app.request.get('fos_user_resetting_form')['plainPa\
29 ssword']['first'] != app.request.get('fos_user_resetting_form')['plainPassword']\
30 ['second']) %}
31                     <div class="alert alert-danger">
32                         {{ 'fos_user.password.mismatch' | trans({}, 'validators'\
33 ) }}
34                     </div>
35                 {% endif %}
36 
37                 {{ form_widget(form._token) }}
38                 <input type="password" id="fos_user_resetting_form_plainPassword\
39 _first" name="fos_user_resetting_form[plainPassword][first]" class="form-control\
40 " required="required" placeholder="{{ 'form.new_password'|trans }}" />
41 
42                 <input type="password" id="fos_user_resetting_form_plainPassword\
43 _second" name="fos_user_resetting_form[plainPassword][second]" class="form-contr\
44 ol" required="required" placeholder="{{ 'form.new_password_confirmation'|trans }\
45 }" />
46 
47                 <input class="btn btn-lg btn-primary btn-block" type="submit" id\
48 ="_submit" name="_submit" value="{{ 'resetting.reset.submit'|trans }}" />
49             </form>
50 
51         </div>
52 
53     </div>
54 {% endblock %}

After you entering the new password and clicking submit, FOSUserBundle will try to redirect you to the fos_user_profile_show route (the profile page which we deleted earlier in route.yml). Since the route no longer exists, you will get an error saying the route no longer exists.

To see what is going on, have a look at vendors/friendsofsymfony/user-bundle/Controller/ResettingController.php::resetAction function. The redirection magic happens after successful form submission.

 1 # vendors/friendsofsymfony/user-bundle/Controller/ResettingController.php
 2 
 3 namespace FOS\UserBundle\Controller;
 4 ...
 5 if ($form->isValid()) {
 6     $event = new FormEvent($form, $request);
 7     $dispatcher->dispatch(FOSUserEvents::RESETTING_RESET_SUCCESS, $event);
 8 
 9     $userManager->updateUser($user);
10 
11     if (null === $response = $event->getResponse()) {
12         $url = $this->generateUrl('fos_user_profile_show');
13         $response = new RedirectResponse($url);
14     }
15 
16     $dispatcher->dispatch(FOSUserEvents::RESETTING_RESET_COMPLETED, new FilterUs\
17 erResponseEvent($user, $request, $response));
18 
19     return $response;
20 }
21 ...

Let’s say we want to change the redirection to user’s dashboard after successful form submission. What can we do?

Customise the Reset Password Process

We noted that the system dispatches a FOSUserEvents::RESETTING_RESET_SUCCESS event after the form submission is successful. This give us the opportunity to change the response so that the whole redirection logic could be skipped.

Let us update the subscriber class to do our own redirection. ``` # src/AppBundle/EventListener/AppSubscriber.php

… use FOSUserBundleFOSUserEvents; use FOSUserBundleEventFormEvent; use SymfonyComponentHttpFoundationRedirectResponse; …

 1 /**
 2  * @return array
 3  */
 4 public static function getSubscribedEvents()
 5 {
 6     // return the subscribed events, their methods and priorities
 7     return array(
 8         ...
 9         FOSUserEvents::RESETTING_RESET_SUCCESS => 'redirectUserAfterPasswordRese\
10 t'
11     );
12 }
13 
14 ...
15 
16 /**
17  * Redirect user to another page after password reset is success
18  *
19  * @param  Configure $event GetResponseUserEvent
20  * @return null
21  */
22 public function redirectUserAfterPasswordReset(FormEvent $event)
23 {
24     $url = $this->container->get('router')->generate('admin');
25     $event->setResponse(new RedirectResponse($url));
26 }
27 ...
 1 Now try to go through the full password reset functionality and see if it works \
 2 for you.
 3 
 4 If everything goes well, you should be redirected to the admin dashboard after t\
 5 he password is reset successfully. Its OK if the dashboard is access denied at t\
 6 he moment.
 7 
 8 ## Update BDD (Optional)
 9 
10 Based on the user story, let us create a new cest file

# in symfony dir -> vendor/bin/codecept generate:cest acceptance As_Test1_User/IWantToResetPasswordWithoutLoggingIn -c src/AppBundle ```

To automate the checking of emails, we need the mailcatcher module for codeception. Let us update composer

1 -> ./scripts/composer require captbaritone/mailcatcher-codeception-module "1.*" \
2 --dev

Let us now update the acceptance.suite.yml to use the new mailcatcher library

 1 # src/AppBundle/tests/acceptance.suite.yml
 2 class_name: AcceptanceTester
 3 modules:
 4     enabled:
 5         - WebDriver:
 6             url: 'http://songbird.app'
 7             host: 172.25.0.5
 8             port: 4444
 9             browser: phantomjs
10             window_size: 1024x768
11             capabilities:
12                 unexpectedAlertBehaviour: 'accept'
13                 webStorageEnabled: true
14         - MailCatcher:
15                 url: 'http://172.25.0.6'
16                 port: '1080'
17         - \Helper\Acceptance

we can now rebuild the libraries # this command will create the mail functions for us to use -> vendor/bin/codecept build -c src/AppBundle

Do a git diff to see all the mail functions added if you want.

1 -> git diff src/AppBundle/tests/_support/_generated/AcceptanceTesterActions.php

See the mailcatcher module for more information.

Let us write the Cest file:

 1 #src/AppBundle/tests/acceptance/As_Test1_User/IWantToResetPasswordWithoutLogging\
 2 InCest.php
 3 
 4 namespace As_Test1_User;
 5 use \AcceptanceTester;
 6 use \Common;
 7 
 8 /**
 9  * AS test1 user
10  * I WANT to reset my password without logging in
11  * SO THAT have a way to access my account in case I forget or loses my password.
12  *
13  * Class IWantToResetPasswordWithoutLoggingInCest
14  * @package As_Test1_User
15  */
16 class IWantToResetPasswordWithoutLoggingInCest
17 {
18     public function _before(AcceptanceTester $I)
19     {
20     }
21 
22     public function _after(AcceptanceTester $I)
23     {
24     }
25 
26     protected function login(AcceptanceTester $I, $username=TEST1_USERNAME, $pas\
27 sword=TEST1_PASSWORD)
28     {
29         Common::login($I, $username, $password);
30     }
31 
32     /**
33      * GIVEN Reset Password Successfully
34      * WHEN I click on forget password in the login page and go through the whol\
35 e resetting process
36      * THEN I should be redirected to the dashboard.
37      *
38      * Scenario 11.1.1
39      * @param AcceptanceTester $I
40      */
41     public function resetPasswordSuccessfully(AcceptanceTester $I)
42     {
43         // reset emails
44         $I->resetEmails();
45         $I->amOnPage('/login');
46         $I->click('forget password');
47         $I->fillField('//input[@id="username"]', 'test1');
48         $I->click('_submit');
49         $I->canSee('It contains a link');
50 
51         // Clear old emails from MailCatcher
52         $I->seeInLastEmail("Hello test1");
53         $link = $I->grabFromLastEmail('@http://(.*)@');
54         $I->amOnUrl($link);
55 
56         // The password has been reset successfully
57         $I->fillField('//input[@id="fos_user_resetting_form_plainPassword_first"\
58 ]', '1111');
59         $I->fillField('//input[@id="fos_user_resetting_form_plainPassword_second\
60 "]', '1111');
61         $I->click('_submit');
62         // at dashbpard, i should see access denied
63         $I->canSee('403');
64         // now at show page
65         $I->amOnPage('/admin/?action=show&entity=User&id=2');
66         $I->canSee('The password has been reset successfully');
67 
68         // now login with the new password
69         $this->login($I, TEST1_USERNAME, '1111');
70 
71         // db has been changed. update it back
72         $I->amOnPage('/admin/?action=edit&entity=User&id=2');
73         $I->fillField('//input[contains(@id, "_plainPassword_first")]', TEST1_US\
74 ERNAME);
75         $I->fillField('//input[contains(@id, "_plainPassword_second")]', TEST1_P\
76 ASSWORD);
77         $I->click('//button[@type="submit"]');
78         // i am on the show page
79         $I->canSeeInCurrentUrl('/admin/?action=show&entity=User&id=2');
80 
81         // i not should be able to login with the old password
82         $this->login($I);
83         $I->canSee('403');
84     }
85 }

ready for testing?

1 -> scripts/runtest As_Test1_User/IWantToResetPasswordWithoutLoggingInCest.php

Summary

We have updated the aesthetics of the Login and request password change pages. By listening to the reset password event, we redirected user to the dashboard when the event is triggered. Finally, we wrote BDD tests to make sure this functionality is repeatable in the future.

Exercises

  • (Optional) Try to be fancy with the login layout and css. How do you use FOSUserBundle’s layout.html.twig?

References

Chapter 12: The Admin Panel Part 2

Let us continue with tweaking EasyAdmin by changing the layout and try more adventurous stuff like creating our own dashboard.

Tweaking the UI

Its easy to change the theme colour and add our own custom css. For the sake of manageability, let us create a new file, design.yml

1 # app/config/easyadmin/design.yml
2 
3 easy_admin:
4   design:
5     brand_color: '#5493ca'
6     assets:
7       css:
8         - /bundles/app/css/style.css

and create a new css

1 # src/AppBundle/Resources/public/css/style.css
2 
3 .user-menu a{
4     color: rgba(255, 255, 255, 0.8);
5 }

Next, We will will overwrite layout.html.twig by copying it into our own views dir.

1 -> cp vendor/javiereguiluz/easyadmin-bundle/Resources/views/default/layout.html.\
2 twig app/Resources/EasyAdminBundle/views/default/

We will change the logo and top menu. The top menu will include a link to edit the user and logout.

 1 # app/Resources/EasyAdminBundle/views/default/layout.html.twig
 2 
 3 ...
 4 
 5 {% block header_logo %}
 6     <a class="logo {{ easyadmin_config('site_name')|length > 14 ? 'logo-long' }}\
 7 " title="{{ easyadmin_config('site_name')|striptags }}" href="{{ path('easyadmin\
 8 ') }}">
 9         <img src="/bundles/app/images/logo.png" />
10     </a>
11 {% endblock header_logo %}
12 ...
13 {% block user_menu %}
14     <span class="sr-only">{{ 'user.logged_in_as'|trans(domain = 'EasyAdminBundle\
15 ') }}</span>
16     <i class="hidden-xs fa fa-user">
17         {% if app.user %}
18             <a href="{{ path('easyadmin') }}/?entity=User&action=show&id={{ app.\
19 user.id }}">{{ app.user.username|default('user.unnamed'|trans(domain = 'EasyAdmi\
20 nBundle')) }}</a>
21         {% else %}
22             {{ 'user.anonymous'|trans(domain = 'EasyAdminBundle') }}
23         {% endif %}
24     </i>
25     <i class="hidden-xs fa fa-sign-out"><a href="{{ path('fos_user_security_logo\
26 ut') }}">Logout</a></i>
27 {% endblock user_menu %}

Let us create the dashboard content.

 1 {%  extends '@EasyAdmin/default/layout.html.twig' %}
 2 
 3 {% block main %}
 4 <p>
 5     Dear {{ app.user.firstname }} {{ app.user.lastname }},
 6 </p>
 7 <p>
 8     You are last logged in at {{ app.user.lastLogin | date('Y-m-d H:i:s') }}
 9 </p>
10 
11 <p>
12     The whole project can be forked from <a href="https://github.com/bernardpeh/\
13 songbird">github</a>
14 </p>
15 
16 {% endblock %}

Noticed how I extended the layout.html.twig and just change the relevant blocks?

Your Dashboard

Let us now create a new dashboard page via the standard way. We need a new route.

 1 # src/AppBundle/Controller/AdminController.php
 2 
 3 use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
 4 use Symfony\Component\HttpFoundation\Request;
 5 ...
 6 
 7 
 8     /**
 9      * @Route("/dashboard", name="dashboard")
10      *
11      * @param Request $request
12      * @return \Symfony\Component\HttpFoundation\Response
13      *
14      */
15     public function dashboardAction(Request $request)
16     {
17         return $this->render('@EasyAdmin/default/dashboard.html.twig');
18     }
19     ...

now copy the assets to the web dir from command line.

1 -> ./scripts/console assets:install --symlink

login and then refresh the browser.

Menu Tweaking

Normal users should not see the left entities menus. Again, let us copy the menu.html.twig modify it.

1 -> cp vendor/javiereguiluz/easyadmin-bundle/Resources/views/default/menu.html.tw\
2 ig app/Resources/EasyAdminBundle/views/default/

and the actual menu.html.twig

 1 # app/Resources/EasyAdminBundle/views/default/menu.html.twig
 2 
 3 ...
 4 {% block main_menu_before %}{% endblock %}
 5 <ul class="sidebar-menu">
 6     {% block main_menu %}
 7         {% if is_granted('ROLE_SUPER_ADMIN') %}
 8             {% for item in easyadmin_config('design.menu') %}
 9                 <li class="{{ item.type == 'divider' ? 'header' }} {{ item.child\
10 ren is not empty ? 'treeview' }} {{ app.request.query.get('menuIndex')|default(-\
11 1) == loop.index0 ? 'active' }} {{ app.request.query.get('submenuIndex')|default\
12 (-1) != -1 ? 'submenu-active' }}">
13                     {{ helper.render_menu_item(item, _entity_config.translation_\
14 domain|default('messages')) }}
15 
16                     {% if item.children|default([]) is not empty %}
17                         <ul class="treeview-menu">
18                             {% for subitem in item.children %}
19                                 <li class="{{ subitem.type == 'divider' ? 'heade\
20 r' }} {{ app.request.query.get('menuIndex')|default(-1) == loop.parent.loop.inde\
21 x0 and app.request.query.get('submenuIndex')|default(-1) == loop.index0 ? 'activ\
22 e' }}">
23                                     {{ helper.render_menu_item(subitem, _entity_\
24 config.translation_domain|default('messages')) }}
25                                 </li>
26                             {% endfor %}
27                         </ul>
28                     {% endif %}
29                 </li>
30             {% endfor %}
31         {% endif %}
32     {% endblock main_menu %}
33 </ul>
34 {% block main_menu_after %}{% endblock %}

This way of filtering menu access is rather stupid and serves just as an exercise for now. We will describe a better way to user manage our admin area in the later chapters.

Removing hardcoding of admin prefix

There is one more thing to mention before we end this chapter. At the moment, the admin url seems to be prefixed to ‘/admin/xx’. What if we want it to be a bit harder to guess, like ‘admin9/xx’? This is a good security feature. Let us create a variable in the config.yml

1 # app/config/config.yml
2 ...
3 parameters:
4     admin_path: admin
5     ...

We can now use this variable in other places. Once we change this variable, it will automatically update the prefix in all places for us.

1 # app/config/routing.yml
2 ...
3 # easyadmin
4 easy_admin_bundle:
5     resource: "@AppBundle/Controller/AdminController.php"
6     type:     annotation
7     prefix:   /%admin_path%
8 
9 ...

and

 1 # app/config/security.yml
 2 ...
 3     firewalls:
 4         dev:
 5             pattern: ^/(_(profiler|wdt|error)|css|images|js)/
 6             security: false
 7 
 8         main:
 9             anonymous: ~
10             pattern: ^/
11             form_login:
12                 provider: fos_userbundle
13                 csrf_provider: security.csrf.token_manager
14                 default_target_path: /%admin_path%/dashboard
15             logout:       true
16 
17     access_control:
18         - { path: ^/login$, role: IS_AUTHENTICATED_ANONYMOUSLY }
19         # We do not allow user registration
20         # - { path: ^/register, role: IS_AUTHENTICATED_ANONYMOUSLY }
21         - { path: ^/resetting, role: IS_AUTHENTICATED_ANONYMOUSLY }
22         - { path: ^/%admin_path%/, role: ROLE_USER }
23 ...

The default admin page is now default_target_path: /%admin_path%/dashboard

Try changing admin_path to something else and check if all the routes have been updated. Let’s change the admin_path back to ‘admin’ after back.

Update BDD Test (Optional)

Now that we have defined the admin layout, we should update BDD tests for seeMyDashboardContent to test on the dashboard content.

Scenario Id Given When Then
10.1.2 See my dashboard content I login correctly I should not see the text “User Management” and should see the text “Dear test1”
Scenario Id Given When Then
10.2.2 See my dashboard content I login correctly I should see the text “User Management” and “Dear Admin”

Also with the left menu installed, we should be clicking on the links rather than going to the page directly. In all the test files, replace all amOnPage methods to “click” method.

1 # go to page directly
2 $I->amOnPage('/admin/?action=list&entity=User');
3 
4 # replace it with
5 $I->click('User Management');

Once you are confident that all your tests are correct, run it and fix it till everything passes.

Summary

In this chapter, we have touched up the admin area and created a simple dashboard block.

The admin area is now looking more polished.

Exercises

  • Try creating another url and view yourself. (Optional)
  • Review and Update BDD for all admin and test1 user stories. (Optional)

References

Chapter 13: Internalisation

No CMS is complete with being being able to support multiple languages (i18n). So far we have been typing english directly into the twig templates. This is quick and easy but not the best practice. What if we are marketing our software to the french market? Wouldn’t it be nice if the interface could be in french rather than english? Its time consuming to create translations for every term that we use but its worth the effort if you want to make your software global.

What about Google Translate? This should be the last option and not be used for professional purposes. Internalisation is something you want to work on early in the software development phase rather than later.

Define User Story

13. Internalisation

Story Id As a I So that I
13.1 test1 user want to be able to switch language can choose my preferred language anytime.

Story ID 13.1: As a test1 user, I want to be able to switch language, so that I can choose my preferred language anytime.

Scenario Id Given When Then
13.1.1 Locale in french I login and switch language to french I should be able to see the dashboard in french till I switched back to english

Translations for the AppBundle

let us create the translation files in the AppBundle. The naming convention for the file is domain.language_prefix.file_format, eg app.en.xlf.

Let us create the translation directory.

1 -> mkdir -p src/AppBundle/Resources/translations

and the actual app.en.xlf

 1 # src/AppBundle/Resources/translations/app.en.xlf
 2 
 3 <?xml version="1.0"?>
 4 <xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
 5     <file source-language="en" datatype="plaintext" original="file.ext">
 6         <body>
 7             <trans-unit id="1">
 8                 <source>dashboard.welcome.title</source>
 9                 <target>Welcome to SongBird CMS</target>
10             </trans-unit>
11             <trans-unit id="2">
12                 <source>dashboard.welcome.credit</source>
13                 <target>The whole project can be forked from <![CDATA[
14                 <a href="https://github.com/bernardpeh/songbird">github</a>
15                 ]]></target>
16             </trans-unit>
17             <trans-unit id="3">
18                 <source>dashboard.welcome.last_login</source>
19                 <target>You last login at</target>
20             </trans-unit>
21         </body>
22     </file>
23 </xliff>

Likewise, we need to create the translation file for french.

 1 # src/AppBundle/Resources/translations/app.fr.xlf
 2 
 3 <?xml version="1.0"?>
 4 <xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
 5     <file source-language="en" datatype="plaintext" original="file.ext">
 6         <body>
 7             <trans-unit id="1">
 8                 <source>dashboard.welcome.title</source>
 9                 <target>Bienvenue à SongBird CMS</target>
10             </trans-unit>
11             <trans-unit id="2">
12                 <source>dashboard.welcome.credit</source>
13                 <target>L'ensemble du projet peut être fourchue de <![CDATA[
14                 <a href="https://github.com/bernardpeh/songbird">github</a>
15                 ]]></target>
16             </trans-unit>
17             <trans-unit id="3">
18                 <source>dashboard.welcome.last_login</source>
19                 <target>Vous dernière connexion au</target>
20             </trans-unit>
21         </body>
22     </file>
23 </xliff>

Update the Dashboard

How do we get the twig files to do the translation? You would have seen glimpse of it while working with the login files.

Let us update the dashboard template.

 1 # app/Resources/EasyAdminBundle/views/default/dashboard.html.twig
 2 
 3 ...
 4 {% block main %}
 5 <p>
 6     Dear {{ app.user.firstname }} {{ app.user.lastname }},
 7 </p>
 8 <p>
 9     {{ 'dashboard.welcome.last_login' | trans({}, 'app') }} {{ app.user.lastLogi\
10 n | date('Y-m-d H:i:s') }}
11 </p>
12 
13 <p>
14     {{ 'dashboard.welcome.credit' | trans({}, 'app') | raw }}
15 </p>
16 
17 {% endblock %}

refresh your browser and have a look. If things are not working, remember to clear the cache.

1 -> ./scripts/resetapp

By default, we are using english, so you should see that the english version is translated. To see all the translations in english for the AppBundle,

1 -> ./scripts/console debug:translation en AppBundle

You should see a lot of missing translations for the FOSUserBundle. Don’t worry about that for now.

Tip: Again, don’t remember this command. Just type in “scripts/console debug:translation” in the command line to see the options.

What about french? How do we set the locale? Just update the parameters in the config.yml

1 # app/config/config.yml
2 ...
3 parameters:
4     locale: fr
5     admin_path: admin
6 ...

Now refresh the dashboard and you should see the welcome block translated.

Its french. Viola!

How do we make the language dynamic? Perhaps we should have a selector on the top menu for users to select the language and persists it throughout the session.

Let us update the translation files

 1 # src/AppBundle/Resources/translations/app.en.xlf
 2 
 3 <?xml version="1.0"?>
 4 <xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
 5     <file source-language="en" datatype="plaintext" original="file.ext">
 6         <body>
 7             ...
 8             <trans-unit id="4">
 9                 <source>admin.link.user_management</source>
10                 <target>User Management</target>
11             </trans-unit>
12             <trans-unit id="5">
13                 <source>admin.link.profile</source>
14                 <target>My Profile</target>
15             </trans-unit>
16         </body>
17     </file>
18 </xliff>

and

 1 # src/AppBundle/Resources/translations/app.fr.xlf
 2 
 3 <?xml version="1.0"?>
 4 <xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
 5     <file source-language="en" datatype="plaintext" original="file.ext">
 6         <body>
 7             ...
 8             <trans-unit id="4">
 9                 <source>admin.link.user_management</source>
10                 <target>Gestion des utilisateurs</target>
11             </trans-unit>
12             <trans-unit id="5">
13                 <source>admin.link.profile</source>
14                 <target>Mon profil</target>
15             </trans-unit>
16         </body>
17     </file>
18 </xliff>

and

1 # app/config/easyadmin/user.yml
2 ...
3     entities:
4         User:
5             class: AppBundle\Entity\User
6             label: admin.link.user_management
7             ...

Now let us update the menu translation in menu.html.twig

 1 # app/Resources/EasyAdminBundle/views/default/menu.html.twig
 2 ...
 3 <ul class="sidebar-menu">
 4     {% block main_menu %}
 5         {% if is_granted('ROLE_SUPER_ADMIN') %}
 6             {% for item in easyadmin_config('design.menu') %}
 7                 <li class="{{ item.type == 'divider' ? 'header' }} {{ item.child\
 8 ren is not empty ? 'treeview' }} {{ app.request.query.get('menuIndex')|default(-\
 9 1) == loop.index0 ? 'active' }} {{ app.request.query.get('submenuIndex')|default\
10 (-1) != -1 ? 'submenu-active' }}">
11 
12                     {{ helper.render_menu_item(item, 'app') }}
13 
14                     {% if item.children|default([]) is not empty %}
15                         <ul class="treeview-menu">
16                             {% for subitem in item.children %}
17                                 <li class="{{ subitem.type == 'divider' ? 'heade\
18 r' }} {{ app.request.query.get('menuIndex')|default(-1) == loop.parent.loop.inde\
19 x0 and app.request.query.get('submenuIndex')|default(-1) == loop.index0 ? 'activ\
20 e' }}">
21                                     {{ helper.render_menu_item(subitem, _entity_\
22 config.translation_domain|default('messages')) }}
23                                 </li>
24                             {% endfor %}
25                         </ul>
26                     {% endif %}
27                 </li>
28             {% endfor %}
29         {% endif %}
30     {% endblock main_menu %}
31 </ul>

Sticky Locale

Let us create the supported languages in config.yml

 1 # app/config/config.yml
 2 ...
 3 parameters:
 4     # set this to english as default
 5     locale: en
 6     supported_lang: [ 'en', 'fr']
 7     admin_path: admin
 8 ...
 9 twig:
10     debug:            "%kernel.debug%"
11     strict_variables: "%kernel.debug%"
12     globals:
13         supported_lang: %supported_lang%
14 ...

We have created a variable called supported_lang (consisting of an array) and passed it to twig as a global variable.

Now in the layout twig

 1 # app/Resources/EasyAdminBundle/views/default/easy_admin/layout.html.twig
 2 
 3 ...
 4 <title>
 5     {% block page_title %}
 6         {{ 'dashboard.welcome.title' | trans({}, 'app') }}
 7     {% endblock %}            
 8 </title>
 9 
10 {% set urlPrefix = (app.environment == 'dev') ? '/app_dev.php/' : '/' %}
11 ...
12 {% block user_menu %}
13     <i class="fa fa-language" aria-hidden="true">
14         <select id="lang" name="lang">
15             {% for lang in supported_lang %}
16                 <option value="{{ lang }}">{{ lang }}</option>
17             {% endfor %}
18         </select>
19     </i>
20     <i class="hidden-xs fa fa-user">
21     {% if app.user %}
22         <a href="{{ path('easyadmin') }}/?entity=User&action=show&id={{ app.user\
23 .id }}">{{ app.user.username|default('user.unnamed'|trans(domain = 'EasyAdminBun\
24 dle')) }}</a>
25     {% else %}
26         {{ 'user.anonymous'|trans(domain = 'EasyAdminBundle') }}
27     {% endif %}
28         </i>
29     <i class="hidden-xs fa fa-sign-out"><a href="{{ path('fos_user_security_logo\
30 ut') }}">{{ 'layout.logout'|trans({}, 'FOSUserBundle') }}</a></i>
31 {% endblock user_menu %}
32 ...
33 {% block body_javascript %}
34     <script>
35         // select the box based on locale
36         var lang = document.getElementById('lang');
37         lang.value = '{{ app.request.getLocale() }}';
38         // redirect user if user change locale
39         lang.addEventListener('change', function () {
40             window.location = '{{ urlPrefix }}' + document.getElementById('lang'\
41 ).value + '/locale';
42         });
43     </script>
44 {% endblock body_javascript %}

Note that we have made logic and css tweaks to the top nav. The new CSS is as follows:

 1 # src/AppBundle/Resources/public/css/styles.css
 2 
 3 .user-menu a{
 4     color: rgba(255, 255, 255, 0.8);
 5 }
 6 
 7 i {
 8     padding: 5px;
 9 }
10 
11 #lang {
12     color: #333;
13 }

The new language dropdown box allows user to select a language and if there is a change in the selection, the user is redirected to a url /{_locale}/locale where the change of locale magic is supposed to happen.

and create a new controller from the command line.

1 -> ./scripts/console generate:controller --controller=AppBundle:Locale -n

Tip: This command can save you some time but not much in this case. You don’t have to memorise it. Always use the “help” option if unsure, ie

1 -> ./scripts/console help generate:controller

The controller code in full:

 1 # src/AppBundle/Controller/LocaleController.php
 2 
 3 namespace AppBundle\Controller;
 4 
 5 use Symfony\Bundle\FrameworkBundle\Controller\Controller;
 6 use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
 7 use Symfony\Component\HttpFoundation\Request;
 8 use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
 9 
10 class LocaleController extends Controller
11 {
12     /**
13      * Redirects user based on their referer
14      *
15      * @Route("/{_locale}/locale", name="app_set_locale")
16      * @Method("GET")
17      */
18     public function setLocaleAction(Request $request, $_locale)
19     {
20         $auth_checker = $this->get('security.authorization_checker');
21 
22         // if referrer exists, redirect to referrer
23         $referer = $request->headers->get('referer');
24         if ($referer) {
25             return $this->redirect($referer);
26         }
27         // if logged in, redirect to dashboard
28         elseif ($auth_checker->isGranted('ROLE_USER')) {
29             return $this->redirectToRoute('dashboard');
30         }
31         // else redirect to homepage
32         else {
33             return $this->redirect('/');
34         }
35     }
36 }

As you can see, the annotation defines the the new route /{_locale}/locale. To make sure that this route is working,

1 -> ./scripts/console debug:router | grep locale
2  app_set_locale                          GET      ANY    ANY  /{_locale}/locale

The AdminController gets the request object and redirects the user to the referer if there is one. If not, it redirects the user to either the admin dashboard or the homepage depending if the user is logged in or not. Again, don’t memorise security.authorization_checker. Google around, make intelligent guesses and use the command line to verify the containers.

1 -> ./scripts/console debug:container | grep security
2 ...

We said that the controller is the place where magic happens… but where is the magic? We haven’t even change the locale session yet! We cannot change it at the controller level because it is too late. We have to change it very early on in the Http workflow

Basically, what we need to do is to hook on to the kernel.request event and modify some logic there. The symfony cookbook has good information on sticky sessions.

We have create an event subscriber before. As a practice, let us create an event listener this time round.

 1 # src/AppBundle/Resources/config/services.yml
 2 
 3 ...
 4   app.locale.listener:
 5     class: AppBundle\EventListener\LocaleListener
 6     arguments:
 7       - "%kernel.default_locale%"
 8     tags:
 9       - { name: kernel.event_listener, event: kernel.request, priority: 17 }
10 ...

Why did we use priority 17? Every listener has a priority. The higher the priority, the earlier the listener will be executed. We want our custom LocaleListener to be earlier than the Kernel’s LocaleListener. According to Kernel events, The kernel LocaleListener has priority 16. Let us go abit higher, ie 17.

Now we need to create the LocaleListener class.

 1 # src/AppBundle/EventListener/LocaleListener.php
 2 
 3 namespace AppBundle\EventListener;
 4 
 5 use Symfony\Component\HttpKernel\Event\GetResponseEvent;
 6 use Symfony\Component\HttpFoundation\RedirectResponse;
 7 
 8 class LocaleListener
 9 {
10     private $defaultLocale;
11 
12     public function __construct($defaultLocale = 'en')
13     {
14         $this->defaultLocale = $defaultLocale;
15     }
16 
17     public function onKernelRequest(GetResponseEvent $event)
18     {
19         $request = $event->getRequest();
20         if (!$request->hasPreviousSession()) {
21             return;
22         }
23 
24         // try to see if the locale has been set as a _locale routing parameter
25         if ($locale = $request->attributes->get('_locale')) {
26             $request->getSession()->set('_locale', $locale);
27         } else {
28             // if no explicit locale has been set on this request, use one from \
29 the session
30             $request->setLocale($request->getSession()->get('_locale', $this->de\
31 faultLocale));
32         }
33     }
34 }

To see what is going on with the events sequencing,

1 # now view the event dispatcher list
2 -> ./scripts/console debug:event-dispatcher kernel.request

Look at the kernel.request section and you should see our custom event listener ranked just above the kernel LocaleListener.

Can you use the AppSubscriber class that we have created to do the same job?

Now, clear the cache and refresh the browser. Try changing the locale dropdown and see for yourself.

1 -> ./scripts/resetapp
dashboard with translation
dashboard with translation

Try changing the priority to 15 of kernel.event_listener tag and see what happens?

Update BDD (Optional)

Let us create the cest file based on the User Story.

1 # in symfony
2 -> vendor/bin/codecept generate:cest acceptance As_Test1_User/IWantToSwitchLangu\
3 ageCest -c src/AppBundle

now within the cest file:

 1 #src/AppBundle/tests/acceptance/As_Test1_User/IWantToSwitchLanguageCest.php
 2 
 3 namespace As_Test1_User;
 4 use \AcceptanceTester;
 5 use \Common;
 6 
 7 /**
 8  * AS test1 user
 9  * I want to be able to switch language
10  * SO THAT I can choose my preferred language anytime.
11  *
12  * Class IWantToSwitchLanguageCest
13  * @package As_Test1_User
14  */
15 class IWantToSwitchLanguageCest
16 {
17     public function _before(AcceptanceTester $I)
18     {
19 	    Common::login($I, TEST1_USERNAME, TEST1_PASSWORD);
20     }
21 
22     public function _after(AcceptanceTester $I)
23     {
24     }
25 
26     /**
27      * GIVEN Locale in french
28      * WHEN I login and switch language to french
29      * THEN I should be able to see the dashboard in french till I switched back\
30  to english
31      *
32      * Scenario 13.1.1
33      */
34     public function localeInFrench(AcceptanceTester $I)
35     {
36         // switch to french
37         $I->selectOption('//select[@id="lang"]', 'fr');
38         // I should be able to see "my profile" in french
39         $I->waitForText('Déconnexion');
40         $I->canSee('Déconnexion');
41         $I->click('test1');
42         // now in show profile page
43         $I->waitForText('Éditer');
44         $I->canSee('Éditer');
45         // now switch back to english
46         $I->selectOption('//select[@id="lang"]', 'en');
47         $I->waitForText('Edit');
48         $I->canSee('Edit');
49     }
50 }

Lets run the test to make sure everything is working.

1 -> ./scripts/runtest As_Test1_User/IWantToSwitchLanguageCest.php

Since the UI has been changed, some previous BDD tests might fail. Fix them and re-run the full BDD tests till everything passes.

1 -> ./scripts/runtest

Summary

In this chapter, we learned how to create translation files and updated the twig files to handle the translation. We have also created a language switcher in the admin area and added a new BDD test to test internalisation.

I am not french and my french translation might not be correct as I was using google translate. The use of french in this book is just an example.

Remember to commit all your changes before moving on to the next chapter.

Exercises

  • Remember all the twig files you have created in chapter 11? Update them to support i18n.
  • (Optional) Try creating translations in other languages other than french.

References

Chapter 14: Uploading Files

Our CMS should allow uploading of files. Let’s say we want to allow user to upload their own profile image. EasyAdmin has nice integration with a popular bundle called VichUploaderBundle.

Update User Stories

Let us update the user stories that we have created before.

Story ID 10.6: As an admin user, I want to manage all users, so that I can control user access of the system.

Scenario Id Given When Then
10.6.1 List all profiles I go show profile page” url I should see a list of all users in a table with image fields

Story ID 10.1: As a test1 user, I want to manage my profile, so that I can update it any time.

Scenario Id Given When Then
10.4.1 Show my profile I go to show profile page I should see test1@songbird.app and an Image field
10.4.5 Delete and Add profile image I go to edit profile page And delete profile image and add a new image I should see an empty profile, previous profile image gone and then a new one appearing in the file system.
10.4.6 Update profile image Only I go to edit profile page And update profile image and submit I should see user profile updated and previous profile image gone from file system.

Install Vich Uploader Bundle

Add the vich uploaded bundle to composer

1 -> ./scripts/composer require vich/uploader-bundle ^1.4

In config.yml, we need to add a few parameters

 1 # app/config/config.yml
 2 ...
 3 parameters:
 4     locale: en
 5     supported_lang: [ 'en', 'fr']
 6     admin_path: admin
 7     app.profile_image.path: /uploads/profiles
 8     ...
 9 # Vich Configuration
10 vich_uploader:
11     db_driver: orm
12     mappings:
13         profile_images:
14             uri_prefix: '%app.profile_image.path%'
15             upload_destination: '%kernel.root_dir%/../web/uploads/profiles'
16             # this will allow all uploaded filenames to be unique
17             namer: vich_uploader.namer_uniqid
18 ...

and in Appkernel.php

 1 # app/AppKernel.php
 2 ...
 3 public function registerBundles()
 4 {
 5     return array(
 6         // ...
 7         new Vich\UploaderBundle\VichUploaderBundle(),
 8     );
 9 }
10 ...

We have to add new image fields to the user table.

 1 # src/AppBundle/Entity/User.php
 2 
 3 namespace AppBundle\Entity;
 4 
 5 use FOS\UserBundle\Model\User as BaseUser;
 6 use Doctrine\ORM\Mapping as ORM;
 7 use Symfony\Component\HttpFoundation\File\File;
 8 use Vich\UploaderBundle\Mapping\Annotation as Vich;
 9 
10 /**
11  * User
12  *
13  * @ORM\Table(name="user")
14  * @ORM\Entity(repositoryClass="AppBundle\Repository\UserRepository")
15  * @ORM\HasLifecycleCallbacks()
16  * @Vich\Uploadable
17  */
18 class User extends BaseUser
19 {
20     ...
21 
22     /**
23      * @ORM\Column(type="string", length=255, nullable=true)
24      * @var string
25      */
26     private $image = '';
27     /**
28      * @Vich\UploadableField(mapping="profile_images", fileNameProperty="image")
29      * @var File
30      */
31     private $imageFile;
32     
33     ...
34 
35     /**
36      * @param File|null $image
37      */
38     public function setImageFile(File $image = null)
39     {
40         $this->imageFile = $image;
41         // at least 1 field needs to change for doctrine to save
42         if ($image) {
43             $this->setModified(new \DateTime());
44         }
45     }
46     /**
47      * @return File
48      */
49     public function getImageFile()
50     {
51         return $this->imageFile;
52     }
53     /**
54      * @param $image
55      */
56     public function setImage($image)
57     {
58         $this->image = $image;
59     }
60     /**
61      * @return string
62      */
63     public function getImage()
64     {
65         return $this->image;
66     }
67     ...

Since we have changed the entity, we have to remember to log the db changes so that we can deploy the db changes to production easily.

1 -> ./scripts/console doctrine:migrations:diff
2 Generated new migration class to "/var/www/symfony/app/../src/AppBundle/Doctrine\
3 Migrations/Version20170208142520.php" from schema differences.
4 ...

Looks good, we can now reset the app

1 -> ./scripts/resetapp

We can now verify that the new image field has been added.

 1 -> ./scripts/mysql "show columns from user"
 2 
 3 +-----------------------+--------------+------+-----+---------+----------------+
 4 | Field                 | Type         | Null | Key | Default | Extra          |
 5 +-----------------------+--------------+------+-----+---------+----------------+
 6 | id                    | int(11)      | NO   | PRI | NULL    | auto_increment |
 7 | username              | varchar(180) | NO   |     | NULL    |                |
 8 | username_canonical    | varchar(180) | NO   | UNI | NULL    |                |
 9 | email                 | varchar(180) | NO   |     | NULL    |                |
10 | email_canonical       | varchar(180) | NO   | UNI | NULL    |                |
11 | enabled               | tinyint(1)   | NO   |     | NULL    |                |
12 | salt                  | varchar(255) | YES  |     | NULL    |                |
13 | password              | varchar(255) | NO   |     | NULL    |                |
14 | last_login            | datetime     | YES  |     | NULL    |                |
15 | confirmation_token    | varchar(180) | YES  | UNI | NULL    |                |
16 | password_requested_at | datetime     | YES  |     | NULL    |                |
17 | roles                 | longtext     | NO   |     | NULL    |                |
18 | firstname             | varchar(255) | YES  |     | NULL    |                |
19 | lastname              | varchar(255) | YES  |     | NULL    |                |
20 | modified              | datetime     | NO   |     | NULL    |                |
21 | created               | datetime     | NO   |     | NULL    |                |
22 | image                 | varchar(255) | NO   |     | NULL    |                |
23 +-----------------------+--------------+------+-----+---------+----------------+

We need to create the new upload folder

1 -> mkdir -p web/uploads/profiles

but we should ignore in git. In .gitignore

1 ...
2 /web/bundles/
3 /web/uploads/
4 ...

Update Fixtures

Let us update the Image field to help us with automate testing.

 1 # src/AppBundle/DataFixtures/ORM/LoadUserData.php
 2 
 3 ...
 4     public function load(ObjectManager $manager)
 5         {
 6             $userManager = $this->container->get('fos_user.user_manager');
 7 
 8             // add admin user
 9             $admin = $userManager->createUser();
10             $admin->setUsername('admin');
11             $admin->setEmail('admin@songbird.app');
12             $admin->setPlainPassword('admin');
13             $userManager->updatePassword($admin);
14             $admin->setEnabled(1);
15             $admin->setFirstname('Admin Firstname');
16             $admin->setLastname('Admin Lastname');
17             $admin->setRoles(array('ROLE_SUPER_ADMIN'));
18             $admin->setImage('test_profile.jpg');
19             $userManager->updateUser($admin);
20 
21             // add test user 1
22             $test1 = $userManager->createUser();
23             $test1->setUsername('test1');
24             $test1->setEmail('test1@songbird.app');
25             $test1->setPlainPassword('test1');
26             $userManager->updatePassword($test1);
27             $test1->setEnabled(1);
28             $test1->setFirstname('test1 Firstname');
29             $test1->setLastname('test1 Lastname');
30             $test1->setImage('test_profile.jpg');
31             $userManager->updateUser($test1);
32 
33             // add test user 2
34             $test2 = $userManager->createUser();
35             $test2->setUsername('test2');
36             $test2->setEmail('test2@songbird.app');
37             $test2->setPlainPassword('test2');
38             $userManager->updatePassword($test2);
39             $test2->setEnabled(1);
40             $test2->setFirstname('test2 Firstname');
41             $test2->setLastname('test2 Lastname');
42             $test2->setImage('test_profile.jpg');
43             $userManager->updateUser($test2);
44 
45             // add test user 3
46             $test3 = $userManager->createUser();
47             $test3->setUsername('test3');
48             $test3->setEmail('test3@songbird.app');
49             $test3->setPlainPassword('test3');
50             $userManager->updatePassword($test3);
51             $test3->setEnabled(0);
52             $test3->setFirstname('test3 Firstname');
53             $test3->setLastname('test3 Lastname');
54             $test3->setImage('test_profile.jpg');
55             $userManager->updateUser($test3);
56 
57             // use this reference in data fixtures elsewhere
58             $this->addReference('admin_user', $admin);
59         }
60     ...

Just create any pic called test_profile.jpg and put it in the src/AppBundle/tests/_data dir. If you run out of ideas, you can use the jpg from my branch. We will update the resetapp script to copy the test_profile.jpg to the web folder.

 1 # scripts/resetapp
 2 
 3 #!/bin/bash
 4 rm -rf var/cache/*
 5 # scripts/console cache:clear --no-warmup
 6 scripts/console doctrine:database:drop --force
 7 scripts/console doctrine:database:create
 8 scripts/console doctrine:schema:create
 9 scripts/console doctrine:fixtures:load -n
10 
11 # copy test data over to web folder
12 cp src/AppBundle/tests/_data/test_profile.jpg web/uploads/profiles/

Update UI

Let us update the UI to include the image field.

 1 # app/config/easyadmin/user.yml
 2 
 3 easy_admin:
 4     entities:
 5         User:
 6             class: AppBundle\Entity\User
 7             label: 'User Management'
 8             # for new user
 9             new:
10                 fields:
11                   - username
12                   - firstname
13                   - lastname
14                   - { property: 'plainPassword', type: 'repeated', type_options:\
15  { type: 'Symfony\Component\Form\Extension\Core\Type\PasswordType', first_option\
16 s: {label: 'Password'}, second_options: {label: 'Repeat Password'}, invalid_mess\
17 age: 'The password fields must match.'}}
18                   - { property: 'email', type: 'email', type_options: { trim: tr\
19 ue } }
20                   - { property: 'imageFile', type: 'vich_image' }
21                   - roles
22                   - enabled
23             edit:
24                   actions: ['-delete', '-list']
25                   fields:
26                     - username
27                     - firstname
28                     - lastname
29                     - { property: 'plainPassword', type: 'repeated', type_option\
30 s: { type: 'Symfony\Component\Form\Extension\Core\Type\PasswordType', required: \
31 false, first_options: {label: 'Password'}, second_options: {label: 'Repeat Passw\
32 ord'}, invalid_message: 'The password fields must match.'}}
33                     - { property: 'email', type: 'email', type_options: { trim: \
34 true } }
35                     - { property: 'imageFile', type: 'vich_image' }
36                     - roles
37                     - enabled
38             show:
39                   actions: ['edit', '-delete', '-list']
40                   fields:
41                     - id
42                     - { property: 'image', type: 'image', base_path: '%app.profi\
43 le_image.path%'}
44                     - username
45                     - firstname
46                     - lastname
47                     - email
48                     - roles
49                     - enabled
50                     - { property: 'last_login', type: 'datetime' }
51                     - modified
52                     - created
53             list:
54                 title: 'User Listing'
55                 actions: ['show']
56                 fields:
57                   - id
58                   - { property: 'image', type: 'image', base_path: '%app.profile\
59 _image.path%'}
60                   - username
61                   - email
62                   - firstname
63                   - lastname
64                   - enabled
65                   - roles
66                   - { property: 'last_login', type: 'datetime' }

Let us resetapp, login and have a look

1 -> ./scripts/resetapp

Update BDD (Optional)

In this chapter, we might need other modules like Db and Filesystem. Let us update our acceptance config file

 1 # src/AppBundle/Tests/acceptance.suite.yml
 2 
 3 class_name: AcceptanceTester
 4 modules:
 5     enabled:
 6         - WebDriver:
 7             url: 'http://songbird.app'
 8             browser: chrome
 9             window_size: 1024x768
10             capabilities:
11                 unexpectedAlertBehaviour: 'accept'
12                 webStorageEnabled: true
13         - MailCatcher:
14             url: 'http://songbird.app'
15             port: '1080'
16         - Filesystem:
17         - Db:
18         - \Helper\Acceptance

and our db credentials

 1 # src/AppBundle/codeception.yml
 2 
 3 actor: Tester
 4 paths:
 5     tests: tests
 6     log: tests/_output
 7     data: tests/_data
 8     support: tests/_support
 9     envs: tests/_envs
10 settings:
11     bootstrap: _bootstrap.php
12     colors: true
13     memory_limit: 1024M
14 extensions:
15     enabled:
16         - Codeception\Extension\RunFailed
17 modules:
18     config:
19         Db:
20             dsn: 'mysql:host=172.25.0.2;dbname=songbird'
21             user: 'root'
22             password: 'root'
23             dump: tests/_data/dump.sql
24             populate: false

now run the build to update the acceptance library

1 -> vendor/bin/codecept build -c src/AppBundle

You should now have lots of new functions to use in AcceptanceTesterActions.php.

Write the stories in this chapter as a practice. Again, get all the test to pass before moving to the next chapter.

Tip 1: To test a file upload, put a file under src/AppBundle/Tests/_data folder and you can then use the attachFile function like so

1 $I->waitForElementVisible('//input[@type="file"]');
2 $I->attachFile('//input[@type="file"]', 'testfile.png');
3 $I->click('Submit');

Remember to commit everything before moving on to the next chapter.

Summary

In this chapter, we have integrated VichuploadBundle with EasyAdminBundle. We made minor change to the ui and added new BDD tests.

Exercises

  • Integrate SonataMediaBundle instead. (Optional)
  • Write BDD Test for User stories in this chapter. (Optional)

References

Chapter 15: Logging User Activities

A proper CMS needs a logging mechanism. We are talking about the admin area, not the front end. If something happens, we need to know what was done and what happened? We can log user activities in a file system but it is not very efficient. File system is good for logging errors - see monolog. Ideally, we need a database solution.

Define User Stories

After the user logs in, we want to record the username, current_url, previous_url, CRUD action, data on every page that the user visits. These data should be recorded in a new table. When the user is deleted, we do not want the logs associated with the user to be deleted, therefore the 2 tables are not related.

There is a popular loggable doctrine extension that we can use. However, it is easy enough to built one for ourselves.

15. Logging User Activitiy

Story Id As a I So that I
15.1 admin user want to manage user logs check on user activity anytime.
15.2 test1 user don’t want to manage user logs don’t breach security

Story ID 15.1: As an admin, I want to manage user logs, so that I can check on user activity anytime.

Scenario Id Given When Then
15.1.1 List user log I click on user log on the left menu I should see more than 1 row in the table
15.1.2 Show user log 1 I go to the first log entry I should see the text “/admin/dashboard”

Story ID 15.2: As test1 user, I don’t want to manage user logs, so that I don’t breach security.

Scenario Id Given When Then
15.2.1 List user log I go to the user log url I should get an access denied message
15.2.2 Show log 1 I go to the show log id 1 url I should get an access denied message
15.2.3 Edit log 1 I go to the edit log id 1 url I should get an access denied message

Implementation

We will create a new entity called UserLog. The UserLog entity should have the following fields: id, username, current_url, referrer, action, data, created.

1 -> ./scripts/console doctrine:generate:entity --entity=AppBundle:UserLog --forma\
2 t=annotation --fields="username:string(255) current_url:text referrer:text(nulla\
3 ble=true) action:string(255) data:text(nullable=true) created:datetime" --no-int\
4 eraction

Again, don’t memorise this command. You can find out more about this command using

1 -> ./scripts/console doctrine:generate:entity --help

or from the online documentation

In the entity, note that we are populating the username field from the user entity but not creating a constraint between the 2 entities. The reason for that is that when we delete the user, we still want to keep the user entries. We haven’t really gone through doctrine yet. You can read more about association mapping here if we want them to be related. We will touch on doctrine again in the later chapters.

  1 # src/AppBundle/Entity/UserLog.php
  2 
  3 <?php
  4 
  5 /**
  6  * UserLog
  7  *
  8  * @ORM\Table(name="user_log")
  9  * @ORM\Entity(repositoryClass="AppBundle\Repository\UserLogRepository")
 10  */
 11 class UserLog
 12 {
 13     /**
 14      * @var int
 15      *
 16      * @ORM\Column(name="id", type="integer")
 17      * @ORM\Id
 18      * @ORM\GeneratedValue(strategy="AUTO")
 19      */
 20     private $id;
 21 
 22     /**
 23      * @var string
 24      *
 25      * @ORM\Column(name="username", type="string", length=255)
 26      */
 27     private $username;
 28 
 29     /**
 30      * @var string
 31      *
 32      * @ORM\Column(name="current_url", type="text")
 33      */
 34     private $current_url;
 35 
 36     /**
 37      * @var string
 38      *
 39      * @ORM\Column(name="referrer", type="text", nullable=true)
 40      */
 41     private $referrer;
 42 
 43     /**
 44      * @var string
 45      *
 46      * @ORM\Column(name="action", type="string", length=255)
 47      */
 48     private $action;
 49 
 50     /**
 51      * @var string
 52      *
 53      * @ORM\Column(name="data", type="text", nullable=true)
 54      */
 55     private $data;
 56 
 57     /**
 58      * @var \DateTime
 59      *
 60      * @ORM\Column(name="created", type="datetime")
 61      */
 62     private $created;
 63 
 64 
 65     /**
 66      * Get id
 67      *
 68      * @return int
 69      */
 70     public function getId()
 71     {
 72         return $this->id;
 73     }
 74 
 75     /**
 76      * Set username
 77      *
 78      * @param string $username
 79      *
 80      * @return UserLog
 81      */
 82     public function setUsername($username)
 83     {
 84         $this->username = $username;
 85 
 86         return $this;
 87     }
 88 
 89     /**
 90      * Get username
 91      *
 92      * @return string
 93      */
 94     public function getUsername()
 95     {
 96         return $this->username;
 97     }
 98 
 99     /**
100      * Set currentUrl
101      *
102      * @param string $currentUrl
103      *
104      * @return UserLog
105      */
106     public function setCurrentUrl($currentUrl)
107     {
108         $this->current_url = $currentUrl;
109 
110         return $this;
111     }
112 
113     /**
114      * Get currentUrl
115      *
116      * @return string
117      */
118     public function getCurrentUrl()
119     {
120         return $this->current_url;
121     }
122 
123     /**
124      * Set referrer
125      *
126      * @param string $referrer
127      *
128      * @return UserLog
129      */
130     public function setReferrer($referrer)
131     {
132         $this->referrer = $referrer;
133 
134         return $this;
135     }
136 
137     /**
138      * Get referrer
139      *
140      * @return string
141      */
142     public function getReferrer()
143     {
144         return $this->referrer;
145     }
146 
147     /**
148      * Set action
149      *
150      * @param string $action
151      *
152      * @return UserLog
153      */
154     public function setAction($action)
155     {
156         $this->action = $action;
157 
158         return $this;
159     }
160 
161     /**
162      * Get action
163      *
164      * @return string
165      */
166     public function getAction()
167     {
168         return $this->action;
169     }
170 
171     /**
172      * Set data
173      *
174      * @param string $data
175      *
176      * @return UserLog
177      */
178     public function setData($data)
179     {
180         $this->data = $data;
181 
182         return $this;
183     }
184 
185     /**
186      * Get data
187      *
188      * @return string
189      */
190     public function getData()
191     {
192         return $this->data;
193     }
194 
195     /**
196      * Set created
197      *
198      * @param \DateTime $created
199      *
200      * @return UserLog
201      */
202     public function setCreated($created)
203     {
204         $this->created = $created;
205 
206         return $this;
207     }
208 
209     /**
210      * Get created
211      *
212      * @return \DateTime
213      */
214     public function getCreated()
215     {
216         return $this->created;
217     }
218 }

Next, we will intercept the kernel.request event.

 1 # src/AppBundle/EventListener/AppSubscriber.php
 2 
 3 ...
 4 use Symfony\Component\HttpKernel\Event\GetResponseEvent;
 5 use Symfony\Component\HttpKernel\KernelEvents;
 6 use AppBundle\Entity\UserLog;
 7 ...
 8     public static function getSubscribedEvents()
 9     {
10         // return the subscribed events, their methods and priorities
11         return array(
12             EasyAdminEvents::PRE_LIST => 'checkUserRights',
13             EasyAdminEvents::PRE_EDIT => 'checkUserRights',
14             EasyAdminEvents::PRE_SHOW => 'checkUserRights',
15             FOSUserEvents::RESETTING_RESET_SUCCESS => 'redirectUserAfterPassword\
16 Reset',
17             KernelEvents::REQUEST => 'onKernelRequest'
18         );
19     }
20     ...
21 
22     /**
23      * We will log request to db on every url change
24      * 
25      * @param GetResponseEvent $event
26      */
27     public function onKernelRequest(GetResponseEvent $event)
28     {
29         $request = $event->getRequest();
30         $current_url = $request->server->get('REQUEST_URI');
31         // ensures we track admin only.
32         $admin_path = $this->container->getParameter('admin_path');
33 
34         // only log admin area and only if user is logged in. Dont log search by\
35  filter
36         if (!is_null($this->container->get('security.token_storage')->getToken()\
37 ) && preg_match('/\/'.$admin_path.'\//', $current_url)
38             && ($request->query->get('filter') === null) && !preg_match('/\/user\
39 log\//', $current_url)) {
40 
41             $em = $this->container->get('doctrine.orm.entity_manager');
42             $log = new UserLog();
43             $log->setData(json_encode($request->request->all()));
44             $log->setUsername($this->container->get('security.token_storage')->g\
45 etToken()->getUser()
46                 ->getUsername());
47             $log->setCurrentUrl($current_url);
48             $log->setReferrer($request->server->get('HTTP_REFERER'));
49             $log->setAction($request->getMethod());
50             $log->setCreated(new \DateTime('now'));
51             $em->persist($log);
52             $em->flush();
53         }
54     }
55         ...

Let us create the new menu.

 1 # app/config/easyadmin/userlog.yml
 2 
 3 easy_admin:
 4     entities:
 5         UserLog:
 6             class: AppBundle\Entity\UserLog
 7             label: admin.link.user_log
 8             show:
 9                   actions: ['list', '-edit', '-delete']
10             list:
11                 actions: ['show', '-edit', '-delete']

and the translation.

1 # src/AppBundle/Resources/translations/app.en.xlf
2 ...
3         <trans-unit id="6">
4             <source>admin.link.user_log</source>
5             <target>User Log</target>
6         </trans-unit>
7 ...

and the french version

1 # src/AppBundle/Resources/translations/app.fr.xlf
2 ...
3         <trans-unit id="6">
4             <source>admin.link.user_log</source>
5             <target>Connexion utilisateur</target>
6         </trans-unit>
7 ...

Now reset the db, re-login again, click on the user log menu and you will see the new menu on the left.

There were db changes, let us capture the change so that we can update production when we need to.

1 -> ./scripts/console doctrine:migrations:diff

and we can reset the db now.

1 -> ./scripts/resetapp

Update BDD (Optional)

Let us create the cest files.

1 -> vendor/bin/codecept generate:cest acceptance As_An_Admin/IWantToManageUserLog\
2  -c src/AppBundle
3 -> vendor/bin/codecept generate:cest acceptance As_Test1_User/IDontWantToManageU\
4 serLog -c src/AppBundle

Tip: The assert module is very useful.

Let us add the assert module

1 # src/AppBundle/Tests/acceptance.suite.yml
2 ...
3         - Asserts:
4         ...

Let us rebuild the libraries

1 -> vendor/bin/codecept build -c src/AppBundle/

Again, I will leave you to write the bdd tests. The more detail your scenario is, the better the test coverage will be. Get all the test to pass and remember to commit everything before moving on to the next chapter.

Summary

In this chapter, we created a new entity called UserLog and used the kernel request event to inject the required request data into the database.

Exercises

  • Modify the UserLog entity such that deleting the user in the User entity will delete the associated user entries in the UserLog entity. (optional)
  • What are the pros and cons of allowing CRUD actions on log entries?
  • Can you use doctrine loggable extension to achieve what was achieved here? (optional)
  • Can you implement automated entity logging using Traits?

References

Chapter 16: Improving Performance and Troubleshooting

If your site uses a lot of javascript and css, one good optimisation strategy is to merge the css and js into just one file each. That way, its one http request rather multiple, improving the loading time. There are also tools to find out where bottlenecks are and fix them.

Install Blackfire

Head to blackfire.io (another great product by sensiolabs) and sign up for an account. In https://blackfire.io/account, get the client and server (id and token). Enter them in .env.

We only need to configure blackfire.

1 # .env
2 # Blackfire io
3 BLACKFIRE_SERVER_ID=your_id
4 BLACKFIRE_SERVER_TOKEN=your_id

Let us add the blackfire image to docker-compose.

 1 ...
 2     blackfire:
 3        image: blackfire/blackfire
 4        environment:
 5             - BLACKFIRE_SERVER_ID=${BLACKFIRE_SERVER_ID}
 6             - BLACKFIRE_SERVER_TOKEN=${BLACKFIRE_SERVER_TOKEN}
 7        networks:
 8            mynet:
 9                ipv4_address: 172.25.0.7
10 ...               

and bring up the image

1 -> docker-compose down
2 -> docker-compose up -d

Upgrade ResetApp Script

./scripts/resetapp is a script that we invoke when we want to remove the cache and reset the database. It is often called if we make changes to the template or before we run test suites. To increase the efficiency of the script, we should allow user to specify if resetting the app requires deleting the cache or not as cache generation is an expensive process and the lag time can cause inconsistency in the tests.

What we need is a an optional switch to allow deleting or cache or not. Maybe even allow an option to load fixtures or not.

 1 # scripts/resetapp
 2 
 3 #!/bin/bash
 4 
 5 usage()
 6 {
 7 cat << EOF
 8 
 9 usage: $0 [options]
10 
11 This script clears the cache, resets the db and install the fixtures
12 
13 OPTIONS:
14    -f      Don't load fixtures
15    -c      Don't clear cache (for all env)
16 EOF
17 exit 1
18 }
19 
20 CLEAR_CACHE=1
21 LOAD_FIXTURES=1
22 while getopts "cf" o; do
23     case "${o}" in
24         c)
25             CLEAR_CACHE=
26             ;;
27         f)
28             LOAD_FIXTURES=
29             ;;
30         *)
31             usage
32             ;;
33     esac
34 done
35 
36 if [[ $CLEAR_CACHE ]]
37 then
38     echo "CLEARING CACHE...";
39     rm -rf app/cache/*
40     # bin/console cache:clear --env=prod --no-warmup
41 fi
42 
43 scripts/console doctrine:database:drop --force
44 scripts/console doctrine:database:create
45 scripts/console doctrine:schema:create
46 
47 if [[ $LOAD_FIXTURES ]]
48 then
49     echo "LOADING FIXTURES...";
50     scripts/console doctrine:fixtures:load -n
51 fi
52 
53 # copy test data over to web folder
54 cp src/AppBundle/tests/_data/test_profile.jpg web/uploads/profiles/

We will now use the “resetapp -c” instead to clear the db only when resetting tests.

1 # scripts/runtest
2 
3 #!/bin/bash
4 scripts/resetapp -c
5 docker-compose exec php vendor/bin/codecept run acceptance $@ -c src/AppBundle

Optimising Composer

We can also optimise composer by building an optimised class map to help speed up searching for namespaces. We can run this once during deployment to production.

1 # scripts/optimize_composer
2 
3 #!/bin/bash
4 
5 # optimise composer
6 scripts/composer dump-autoload --optimize --no-dev --classmap-authoritative

Minimising JS/CSS

You might have heard of using assetic to manage assets and minimising JS/CSS from The book and The Cookbook. The nice thing about using assetic is that you can do compilation of sass or less files on the fly. If you are unsure about css preprocessor, I recommend checking them out. At the time of writing, sass is more popular.

The has been a lot of innovation in frontend technologies especially with node in recent years. gulpjs is being widely to minify js and css.

Assuming you are using mac, make sure you have homebrew. If not, install it

1 -> ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/mast\
2 er/install)"

Install node if not done.

1 -> brew install node

If successful, “node -v” and “npm -v” should return values. Now we create package.json.

 1 # in symfony folder
 2 -> npm init
 3 name: (songbird)
 4 version: (1.0.0)
 5 description: gulp config
 6 entry point: (index.js) gulpfile.js
 7 test command:
 8 git repository:
 9 keywords:
10 author:
11 license: (ISC)

Follow through the prompts. Then install bower.

1 -> sudo npm install -g bower

Like npm, let us create the bower.json

1 -> bower init

Like before, follow through the prompts. Now, let us install all the bower dependencies.

1 -> bower install jquery bootstrap --save-dev

Jquery and bootstrap are the 2 most widely used libraries. It make sense for us to include the libraries outside of AppBundle.

Let us install gulp and all the dependencies.

1 # in symfony folder
2 -> npm install gulp gulp-util gulp-cat gulp-uglify gulp-uglifycss gulp-less gulp\
3 -sass gulp-concat gulp-sourcemaps gulp-if --save

if everything is successful, we should see these new files and folders:

1 bower.json
2 /bower_components
3 package.json
4 /node_modules

We only need the json files, we can put the bower_components and node_modules in .gitignore

1 # .gitignore
2 ...
3 /node_modules
4 /bower_components
5 ...

package.json is important. We want the default node js file to be gulpfile.js. The package.json should look something like this:

 1 # package.json
 2 {
 3   "name": "songbird",
 4   "version": "1.0.0",
 5   "description": "gulp config",
 6   "main": "gulpfile.js",
 7   "scripts": {
 8     "test": "echo \"Error: no test specified\" && exit 1"
 9   },
10   "author": "",
11   "license": "ISC",
12   "dependencies": {
13     "gulp": "^3.9.1",
14     "gulp-cat": "^0.3.3",
15     "gulp-concat": "^2.6.0",
16     "gulp-if": "^2.0.1",
17     "gulp-less": "^3.1.0",
18     "gulp-sass": "^2.3.2",
19     "gulp-sourcemaps": "^1.6.0",
20     "gulp-uglify": "^2.0.0",
21     "gulp-uglifycss": "^1.0.6",
22     "gulp-util": "^3.0.7"
23   }
24 }

Let us create the gulpfile.js

 1 # gulpfile.js
 2 var gulp = require('gulp');
 3 var gulpif = require('gulp-if');
 4 var uglify = require('gulp-uglify');
 5 var uglifycss = require('gulp-uglifycss');
 6 var less = require('gulp-less');
 7 var sass = require('gulp-sass');
 8 var concat = require('gulp-concat');
 9 var sourcemaps = require('gulp-sourcemaps');
10 var exec = require('child_process').exec;
11 
12 // Minify JS
13 gulp.task('js', function () {
14     return gulp.src(['bower_components/jquery/dist/jquery.js',
15         'bower_components/bootstrap/dist/js/bootstrap.js'])
16         .pipe(concat('javascript.js'))
17         .pipe(uglify())
18         .pipe(sourcemaps.write('./'))
19         .pipe(gulp.dest('web/minified/js'));
20 });
21 
22 // Minify CSS
23 gulp.task('css', function () {
24     return gulp.src([
25         'bower_components/bootstrap/dist/css/bootstrap.css',
26         'src/AppBundle/Resources/public/less/*.less',
27         'src/AppBundle/Resources/public/sass/*.scss',
28         'src/AppBundle/Resources/public/css/*.css'])
29         .pipe(gulpif(/[.]less/, less()))
30         .pipe(gulpif(/[.]scss/, sass()))
31         .pipe(concat('styles.css'))
32         .pipe(uglifycss())
33         .pipe(sourcemaps.write('./'))
34         .pipe(gulp.dest('web/minified/css'));
35 });
36 
37 // Copy Fonts
38 gulp.task('fonts', function() {
39     return gulp.src('bower_components/bootstrap/fonts/*.{ttf,woff,woff2,eof,svg}\
40 ')
41     .pipe(gulp.dest('web/minified/fonts'));
42 });
43 
44 gulp.task('installAssets', function() {
45     exec('./scripts/console assets:install --symlink', logStdOutAndErr);
46 });
47 
48 //define executable tasks when running "gulp" command
49 gulp.task('default', ['js', 'css', 'fonts', 'installAssets']);
50 
51 gulp.task('watch', function () {
52     var onChange = function (event) {
53         console.log('File '+event.path+' has been '+event.type);
54     };
55     gulp.watch('src/AppBundle/Resources/public/js/*.js', ['default'])
56         .on('change', onChange);
57 
58     gulp.watch('src/AppBundle/Resources/public/less/*.less', ['default'])
59         .on('change', onChange);
60 
61     gulp.watch('src/AppBundle/Resources/public/sass/*.scss', ['default'])
62         .on('change', onChange);
63 
64     gulp.watch('src/AppBundle/Resources/public/css/*.css', ['default'])
65         .on('change', onChange);
66 });
67 
68 // show exec output
69 var logStdOutAndErr = function (err, stdout, stderr) {
70     console.log(stdout + stderr);
71 };

In short, this gulpfile.js simply says minify all relevant js and css, then copy the js, css and fonts to the web/minified directory.

Since we are only using 1 css and js file, we only need to include the files once in the base template.

 1 #  src/AppBundle/Resources/views/base.html.twig
 2 ....
 3 {% block stylesheets %}
 4     <link href="{{ asset('minified/css/styles.css') }}" rel="stylesheet" />
 5 {% endblock %}
 6 ...
 7 {% block script %}
 8     <script src="{{ asset('minified/js/javascript.js') }}"></script>
 9 {% endblock %}
10 ...

We no longer need to use separate css for the custom views. Remove all the stylesheet blocks in src/AppBundle/Resources/FOSUserBundle/views/Resetting and src/AppBundle/Resources/FOSUserBundle/views/Security.

Let us update gitignore:

1 # .gitignore
2 ...
3 /web/minified/
4 ...

and create the minified dir

1 mkdir -p web/minified

Since we are using bower to include common js and css, we can remove all the unncessary css and js that we have included from the previous chapters.

1 git rm src/AppBundle/Resources/public/css/bootstrap*

To compile the js and css, open up another terminal and enter

1 -> gulp

if you want to auto compile js or css files when you change the sass or javascript files

1 -> gulp watch

If everything is successful, you will see the new dir and files created under web/minified dir.

Now go to songbird.app/login, and verify the new javascript.js and styles.css are included by viewing the source code.

Troubleshooting

You should by now aware of the debug toolbar (profiler) at the bottom of the screen as you access the app_dev.php/* url. The toolbar provide lots of debugging information for the application like the route name, db queries, render time, memory usage, translation…etc.

If you have been observant enough, you should have seen the red alert on the toolbar. Try logging in as admin and go to http://songbird.app:8000/app_dev.php/admin/?entity=User&action=list and look at the toolbar. What happened?

You would see the obvious alert icon in the toolbar… Clicking on the red icon will tell you that you have missing translations.

There will be lots of “messages” under the domain column if there is no translation for certain text.

How would you fix the translation errors?

How about the performance link? What can you see from there?

Using the debug toolbar is straight forward and should be self explanatory.

Tip: PHP developers should be aware of the print_r or var_dump command to dump objects or variables. Try doing it with Symfony and your browser will crash. In PHP, use var_dumper and in twig, use dump instead.

Identifying bottlenecks with blackfire.io

Even though the in-built debug profiler can provide the rendering time and performance information but it doesn’t go into detail where the bottlenecks are. To find out where the bottlenecks are, we need Blackfire.

You should have installed blackfire from the previous section.

To make use of Blackfire is easy, install the google chrome companion extension.

Once done, you should see a new blackfire icon on the top right of google chrome. Let us load the user management page:

1 http://songbird.app:8000/admin/?entity=User&action=list

and click “create a new reference”, then click on on the Profile button.

At this point, the chrome browser will interact with the php docker container and tells the blackfire agent to pass the diagnostic data over to blackfire server. You will also see some values in the blackfire toolbar. So we are talking about a few sec of processing time. This is slow and thats because we are using docker.

blackfire profile
blackfire profile

Once done, you will see a new profile toolbar. Give the profile a name, say “songbird prod default”.

Reverse Proxy and APCU

We will do another optimisation. Symfony comes with a reverse proxy and the ability to use apcu, let us enable it.

 1 # web/app.php
 2 
 3 use Symfony\Component\HttpFoundation\Request;
 4 use Symfony\Component\ClassLoader\ApcClassLoader;
 5 
 6 /**
 7  * @var Composer\Autoload\ClassLoader
 8  */
 9 $loader = require __DIR__.'/../app/autoload.php';
10 include_once __DIR__.'/../app/bootstrap.php.cache';
11 
12 $apcLoader = new ApcClassLoader(sha1('songbird'), $loader);
13 $loader->unregister();
14 $apcLoader->register(true);
15 
16 $kernel = new AppKernel('prod', true);
17 $kernel->loadClassCache();
18 $kernel = new AppCache($kernel);
19 
20 // When using the HttpCache, you need to call the method in your front controlle\
21 r instead of relying on the configuration parameter
22 Request::enableHttpMethodParameterOverride();
23 $request = Request::createFromGlobals();
24 $response = $kernel->handle($request);
25 $response->send();
26 $kernel->terminate($request, $response);

Now refresh the page and then click on the blackfire icon again. In the blackfire toolbar, compare it with the previous profile.

Did you see any improvements in the loading time. What was the improvement?

Click on “View comparision”

I was merely scrapping the surface of blackfire. I suggest you do the 24 days of blackfire tutorials if you want to dig in deeper.

Fix Coding Standards with PHP-CS-Fixer

PHP-CS-Fixer automatically fixes coding standards. Its always a good idea to use it to clean up your code before commiting.

1  -> ./scripts/composer require friendsofphp/php-cs-fixer --dev

once this php-cs-fixer is installed, we can use it from the command line like so

1  -> ./vendor/bin/php-cs-fixer fix app/
2        1) AppKernel.php
3        2) autoload.php
4     Fixed all files in 0.914 seconds, 7.000 MB memory used

Let us add the php-cs-fixer cache dir to .gitignore as well

1  # .gitignore
2  ...
3  .php_cs.cache

Do it for the src directory as well. Run all the tests. We can now commit all the fixed files once we are happy with the results.

Summary

In this chapter, we briefly discussed several optimisation strategies. We installed blackfire, minified css and js using gulpjs. We have also refactored the runtest script so that it doesn’t clear the cache every time it starts a new test. lastly, we walked through troubleshooting using the web toolbar and blackfire.io.

Exercises

  • Using the debug profiler, fix all the translation errors.
  • What other performance enhancing tools can you think of?
  • Try minimising the js and css in the admin area?

References

Chapter 17: The Page Manager Part 1

So far, we have been very lazy (a good thing?). We have offloaded bulk of the CMS functionality to FOS and EasyAdmin bundles. In this chapter, we will create a simple reusable page bundle and the bulk of the logic ourselves. Let us call this NestablePageBundle.

The Plan

We want our page bundle to have no dependency on other bundles like FOSUserbundle. Each page should have a unique slug and a couple of meta data such as title, short description, long description, created_date…etc. We will be using nestable js to allow drag and drop + page nesting using ajax.

We will create 2 entities. The first entity is the Page entity and will consist of simple attributes like id, slug, sequence, parent and children id…etc. The second entity will be the PageMeta entity consisting of attributes like name, locale, title, short description and content. The relationship between the Page and PageMeta entity will be one to many.

This bundle creation is for illustration only and has lots of rooms for improvement.

Define User Stories

Since SongbirdNestableBundle is going to be decoupled from AppBundle, so we will need 2 sets of tests, one for SongbirdNestableBundle and one for the AppBundle. We will worry about the AppBundle tests in the next chapter.

SongbirdNestableBundle

Story Id As a I So that I
17.1 test2 user want to manage pages can update them anytime.

Story ID 17.1: As test2 user, I want to manage pages, so that I can update them anytime.

Scenario Id Given When Then
17.11 List Pages I go to /songbird_page I should see the why_songbird slug under the about slug
17.12 Show contact us page I go to /songbird_page/5 I should see the word “contact_us” and the word “Created”
17.13 Reorder home I simulate a drag and drop of the home menu to under the about menu and submit the post data to /songbird_page/reorder I should see “reordered successfully message” in the response and menus should be updated
17.14 Edit home page meta I go to edit homepage url and update the menu title of “Home” to “Home1” and click update I should see the text “successfully updated” message
17.15 Create and delete test pagemeta go to /new and fill in details and click “Create” button, then go to test page and click add new meta and fill in the details and click “create” button, then click delete button I should see the new page and pagemeta being created and pagemeta deleted
17.16 Delete contact us page go to /songbird_page/5 and click “Delete” button I should see the contact_us slug no longer available in the listing page. Page id 5 should no longer be found in the pagemeta table.

Create Our Own Bundle Generation Script (Optional)

The default bundle generation script is cool. Let us customise it further to make our life easier. We will create a custom script to generate songbird bundles.

 1 # scripts/createbundle
 2 
 3 #!/bin/bash
 4 
 5 if [ -z "$*" ]; then
 6  	echo -e "\nUsage: $0 VendorName BundleName\n";
 7  	exit;
 8 fi
 9 
10 # using symfony bundle generation script is a quick way to generate bundles but \
11 doesn't mean its the best way.
12 scripts/console generate:bundle --namespace=$1/$2 --dir=src --bundle-name=$1$2 -\
13 -format=annotation --no-interaction
14 rm -rf src/$1/$2/Tests/*
15 rm -rf src/$1/$2/Resources/views/Default
16 rm src/$1/$2/Controller/DefaultController.php
17 
18 touch src/$1/$2/Resources/views/.gitkeep
19 touch src/$1/$2/Controller/.gitkeep

now let us run the script

1 -> chmod u+x ./scripts/createbundle
2 -> ./scripts/createbundle Songbird NestablePageBundle

run a git status and you will see that the script does a lot of work for you.

 1 -> git status
 2 
 3 Changes not staged for commit:
 4   (use "git add <file>..." to update what will be committed)
 5   (use "git checkout -- <file>..." to discard changes in working directory)
 6 
 7 	modified:   app/AppKernel.php
 8 	modified:   app/config/routing.yml
 9 	modified:   app/config/config.yml
10 
11 Untracked files:
12   (use "git add <file>..." to include in what will be committed)
13 
14 	scripts/createbundle
15         src/Songbird

Implementation

Let us create the entities.

For Page entity:

1 -> ./scripts/console generate:doctrine:entity --entity=SongbirdNestablePageBundl\
2 e:Page --format=annotation --fields="slug:string(length=255 unique=true) isPubli\
3 shed:boolean(nullable=true) sequence:integer(nullable=true) modified:datetime cr\
4 eated:datetime" --no-interaction

and for PageMeta entity:

1 -> ./scripts/console generate:doctrine:entity --entity=SongbirdNestablePageBundl\
2 e:PageMeta --format=annotation --fields="page_title:string(length=255) menu_titl\
3 e:string(255) locale:string(4) short_description:text(nullable=true) content:tex\
4 t(nullable=true)" --no-interaction

We now need to update the relationship between the 2 entities:

 1 # src/Songbird/NestablePageBundle/Entity/Page.php
 2 
 3 namespace Songbird\NestablePageBundle\Entity;
 4 use Doctrine\ORM\Mapping as ORM;
 5 
 6 /**
 7  * Page
 8  *
 9  * @ORM\Table(name="page")
10  * @ORM\Entity(repositoryClass="Songbird\NestablePageBundle\Entity\PageRepositor\
11 y")
12  * @ORM\HasLifecycleCallbacks()
13  */
14 class Page
15 {
16 ...
17     /**
18      * @var string
19      *
20      * @ORM\Column(name="slug", type="string", length=255, unique=true)
21      */
22     private $slug;
23 
24     /**
25      * @ORM\ManyToOne(targetEntity="Page", inversedBy="children")
26      * @ORM\JoinColumn(name="parent_id", referencedColumnName="id", onDelete="CA\
27 SCADE")}
28      * @ORM\OrderBy({"sequence" = "ASC"})
29      */
30     private $parent;
31 
32     /**
33      * @var boolean
34      *
35      * @ORM\Column(name="isPublished", type="boolean", nullable=true)
36      */
37     private $isPublished;
38 
39     /**
40      * @var integer
41      *
42      * @ORM\Column(name="sequence", type="integer", nullable=true)
43      */
44     private $sequence;
45 
46     /**
47      * @ORM\OneToMany(targetEntity="Page", mappedBy="parent")
48      * @ORM\OrderBy({"sequence" = "ASC"})
49      */
50     private $children;
51 
52     /**
53      * @ORM\OneToMany(targetEntity="Songbird\NestablePageBundle\Entity\PageMeta"\
54 , mappedBy="page", cascade={"persist"})
55      */
56     private $pageMetas;
57 
58     ...
59 
60     /**
61      * @ORM\PrePersist
62      */
63     public function prePersist()
64     {
65         // update the modified time
66         $this->setModified(new \DateTime());
67 
68         // for newly created entries
69         if ($this->getCreated() == null) {
70             $this->setCreated(new \DateTime('now'));
71         }
72         $this->created = new \DateTime();
73     }
74 
75     /**
76      * convert obj to string
77      *
78      * @return string
79      */
80     public function __toString() {
81         return $this->slug;
82     }
83 ...

and

 1 # src/Songbird/NestablePageBundle/Entity/PageMeta.php
 2 ...
 3     /**
 4      * @ORM\ManyToOne(targetEntity="Songbird\NestablePageBundle\Entity\Page", in\
 5 versedBy="pageMetas")
 6      * @ORM\JoinColumn(name="page_id", referencedColumnName="id", onDelete="CASC\
 7 ADE")}
 8      */
 9     private $page;
10     ...
11     /**
12      * @var string
13      *
14      * @ORM\Column(name="short_description", type="text", nullable=true)
15      */
16     private $short_description;
17 
18     /**
19      * @var string
20      *
21      * @ORM\Column(name="content", type="text", nullable=true)
22      */
23     private $content;
24     ...
25     /**
26      * constructor
27      */
28     public function __construct()
29     {
30         // default values
31         $this->locale = 'en';
32     }
33     ...
34     public function newAction(Request $request)
35     {
36         // Noticed that CRUD tries to be intelligent and change pageMeta to page\
37 Metum but its not really that intelligent
38         $pageMetum = new PageMeta();

There were some new doctrine association annotations used here, notably @ManyToOne and @OneToMany are the most common. Establishing the right associations can save lots of time when managing table relationships. For PageMeta.php, we set the default locale to “en” if none is set.

We can now auto generate the stubs for the 2 entities:

1 -> ./scripts/console generate:doctrine:entities SongbirdNestablePageBundle --no-\
2 backup
3 Generating entities for bundle "SongbirdNestablePageBundle"
4   > generating Songbird\NestablePageBundle\Entity\Page
5   > generating Songbird\NestablePageBundle\Entity\PageMeta

This command helps us to generate the getters and setters for the new variables that we have added. For the page entity for example, you should see new functions like setParent() and getParent() being added - another huge time saver.

We will also create a helper to help us find the page meta entries based on locale.

 1 # src/Songbird/NestablePageBundle/Entity/PageRepository.php
 2 
 3 namespace Songbird\NestablePageBundle\Repository;
 4 
 5 /**
 6  * PageRepository
 7  *
 8  * This class was generated by the Doctrine ORM. Add your own custom
 9  * repository methods below.
10  */
11 class PageRepository extends EntityRepository
12 {
13 
14     public function findPageMetaByLocale($slug, $locale) {
15 
16         $query = $this->createQueryBuilder('p')
17             ->select('p', 'pm')
18             ->Join('p.pageMetas','pm')
19             ->where('p.isPublished = :isPublished')
20             ->andWhere('pm.locale = :locale')
21             ->andWhere('p.slug = :slug')
22             ->setParameter('isPublished', '1')
23             ->setParameter('locale', $locale)
24             ->setParameter('slug', $slug)
25             ->getQuery();
26 
27         return $query->getOneOrNullResult();
28 
29     }
30 
31     public function findParent() {
32 
33         $query = $this->createQueryBuilder('p')
34             ->select('p')
35             ->where('p.isPublished = :isPublished')
36             ->andWhere('p.parent is null')
37             ->setParameter('isPublished', '1')
38             ->orderBy('p.sequence', 'asc')
39             ->getQuery();
40 
41         return $query->getResult();
42 
43     }
44 }

Before we reset the app, let us create the doctrine migration file so that we can deploy this db changes to production (if we have one). It is a good practice to do that.

1 -> ./scripts/console doctrine:migrations:diff

reset the app now and verify that the 2 new tables, ie page and page_meta being created in the songbird db.

1 -> ./scripts/resetapp

We are going to use a variant of nestable.js to create our draggable menu. Let us create the js and css directories.

1 -> mkdir -p src/Songbird/NestablePageBundle/Resources/public/{js,css}

Download jquery.nestable.js and put it under src/Songbird/NestablePageBundle/Resources/public/js/jquery.nestable.js

1 -> cd src/Songbird/NestablePageBundle/Resources/public/js
2 -> wget http://code.jquery.com/jquery-1.11.3.min.js
3 -> wget https://raw.githubusercontent.com/bernardpeh/Nestable/master/jquery.nest\
4 able.js

Now let us create the css

 1 # src/Songbird/NestablePageBundle/Resources/public/css/styles.css
 2 .dd { position: relative; display: block; margin: 0; padding: 0; max-width: 600p\
 3 x; list-style: none; font-siz
 4 e: 13px; line-height: 20px; }
 5 
 6 .dd-list { display: block; position: relative; margin: 0; padding: 0; list-style\
 7 : none; }
 8 .dd-list .dd-list { padding-left: 30px; }
 9 .dd-collapsed .dd-list { display: none; }
10 
11 .dd-item,
12 .dd-empty,
13 .dd-placeholder { display: block; position: relative; margin: 0; padding: 0; min\
14 -height: 20px; font-size: 13p
15 x; line-height: 20px; }
16 
17 .dd-handle { display: block; height: 30px; margin: 5px 0; padding: 5px 10px; col\
18 or: #333; text-decoration: no
19 ne; font-weight: bold; border: 1px solid #ccc;
20     background: #fafafa;
21     background: -webkit-linear-gradient(top, #fafafa 0%, #eee 100%);
22     background:    -moz-linear-gradient(top, #fafafa 0%, #eee 100%);
23     background:         linear-gradient(top, #fafafa 0%, #eee 100%);
24     -webkit-border-radius: 3px;
25             border-radius: 3px;
26     box-sizing: border-box; -moz-box-sizing: border-box;
27 }
28 .dd-handle:hover { color: #2ea8e5; background: #fff; }
29 .dd-item > button { display: block; position: relative; cursor: pointer; float: \
30 left; width: 25px; height: 20px; margin: 5px 0; padding: 0; text-indent: 100%; w\
31 hite-space: nowrap; overflow: hidden; border: 0; background: transparent; font-s\
32 ize: 12px; line-height: 1; text-align: center; font-weight: bold; }
33 .dd-item > button:before { content: '+'; display: block; position: absolute; wid\
34 th: 100%; text-align: center; text-indent: 0; }
35 .dd-item > button[data-action="collapse"]:before { content: '-'; }
36 
37 .dd-placeholder,
38 .dd-empty { margin: 5px 0; padding: 0; min-height: 30px; background: #f2fbff; bo\
39 rder: 1px dashed #b6bcbf; box-sizing: border-box; -moz-box-sizing: border-box; }
40 .dd-empty { border: 1px dashed #bbb; min-height: 100px; background-color: #e5e5e\
41 5;
42     background-image: -webkit-linear-gradient(45deg, #fff 25%, transparent 25%, \
43 transparent 75%, #fff 75%, #fff),
44                       -webkit-linear-gradient(45deg, #fff 25%, transparent 25%, \
45 transparent 75%, #fff 75%, #fff);
46     background-image:    -moz-linear-gradient(45deg, #fff 25%, transparent 25%, \
47 transparent 75%, #fff 75%, #fff),
48                          -moz-linear-gradient(45deg, #fff 25%, transparent 25%, \
49 transparent 75%, #fff 75%, #fff);
50     background-image:         linear-gradient(45deg, #fff 25%, transparent 25%, \
51 transparent 75%, #fff 75%, #fff),
52                               linear-gradient(45deg, #fff 25%, transparent 25%, \
53 transparent 75%, #fff 75%, #fff);
54     background-size: 60px 60px;
55     background-position: 0 0, 30px 30px;
56 }
57 
58 .dd-dragel { position: absolute; pointer-events: none; z-index: 9999; }
59 .dd-dragel > .dd-item .dd-handle { margin-top: 0; }
60 .dd-dragel .dd-handle {
61     -webkit-box-shadow: 2px 4px 6px 0 rgba(0,0,0,.1);
62             box-shadow: 2px 4px 6px 0 rgba(0,0,0,.1);
63 }

Let us now create the translation files.

The english version:

 1 # src/Songbird/NestablePageBundle/Resources/translations/SongbirdNestablePageBun\
 2 dle.en.xlf
 3 <?xml version="1.0"?>
 4 <xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
 5     <file source-language="en" datatype="plaintext" original="file.ext">
 6         <body>
 7             <trans-unit id="1">
 8                 <source>menu.page_management</source>
 9                 <target>Page Management</target>
10             </trans-unit>
11             <trans-unit id="2">
12                 <source>flash_reorder_instructions</source>
13                 <target>click and drag to reorder menu</target>
14             </trans-unit>
15             <trans-unit id="3">
16                 <source>flash_reorder_edit_success</source>
17                 <target>menu has been reordered successfully</target>
18             </trans-unit>
19         </body>
20     </file>
21 </xliff>

and the french version:

 1 # src/Songbird/NestablePageBundle/Resources/translations/SongbirdNestablePageBun\
 2 dle.fr.xlf
 3 <?xml version="1.0"?>
 4 <xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
 5     <file source-language="en" datatype="plaintext" original="file.ext">
 6         <body>
 7             <trans-unit id="1">
 8                 <source>menu.page_management</source>
 9                 <target>Gestion de la page</target>
10             </trans-unit>
11             <trans-unit id="2">
12                 <source>flash_reorder_instructions</source>
13                 <target>cliquer et faire glisser pour réorganiser le menu</targe\
14 t>
15             </trans-unit>
16             <trans-unit id="3">
17                 <source>flash_reorder_edit_success</source>
18                 <target>menu a été réorganisé avec succès</target>
19             </trans-unit>
20         </body>
21     </file>
22 </xliff>

We will now generate CRUD for the 2 entities in a quick way:

1 # dont memorise this. You can get help using the --help option
2 
3 -> ./scripts/console g:doctrine:crud --entity=SongbirdNestablePageBundle:Page --\
4 route-prefix=songbird_page --with-write -n
5 -> ./scripts/console g:doctrine:crud --entity=SongbirdNestablePageBundle:PageMet\
6 a --route-prefix=songbird_pagemeta --with-write -n

Noticed we use “g” as a shortcut to “generate” in the command line. We’ve added the route-prefix to make sure our path is unique so that it can be reused with minimal changes.

Create Sample Data

Let us populate sample data to work with. Say we want 3 parent menu, Homepage, “About Us” and “Contact Us” and a couple of submenus.

  1 # src/Songbird/NestablePageBundle/DataFixtures/ORM/LoadPageData.php
  2 
  3 namespace Songbird\NestablePageBundle\DataFixtures\ORM;
  4 
  5 use Doctrine\Common\DataFixtures\AbstractFixture;
  6 use Doctrine\Common\Persistence\ObjectManager;
  7 use Symfony\Component\DependencyInjection\ContainerAwareInterface;
  8 use Symfony\Component\DependencyInjection\ContainerInterface;
  9 use Songbird\NestablePageBundle\Entity\Page;
 10 use Songbird\NestablePageBundle\Entity\PageMeta;
 11 
 12 class LoadPageData extends AbstractFixture implements ContainerAwareInterface
 13 {
 14 
 15     /**
 16      * @var ContainerInterface
 17      */
 18     private $container;
 19 
 20     /**
 21      * {@inheritDoc}
 22      */
 23     public function setContainer(ContainerInterface $container = null)
 24     {
 25         $this->container = $container;
 26     }
 27 
 28     /**
 29      * {@inheritDoc}
 30      */
 31     public function load(ObjectManager $manager)
 32     {
 33 
 34         $homepage = new Page();
 35         $homepage->setSlug('home');
 36         $homepage->setIsPublished(1);
 37         $homepage->setSequence(0);
 38         // there is no relationship with the user entity atm
 39         // $homepage->setUser($this->getReference('admin_user'));
 40         $manager->persist($homepage);
 41 
 42         $homemetaEN = new PageMeta();
 43         $homemetaEN->setPage($homepage);
 44         $homemetaEN->setMenuTitle('Home');
 45         $homemetaEN->setPageTitle('Welcome to SongBird CMS Demo');
 46         $homemetaEN->setShortDescription('Welcome to SongBird CMS Demo');
 47         $homemetaEN->setContent('<p>SongBird is a simple CMS built with popular \
 48 bundles like FOSUserBundle and SonataAdminBundle.
 49             The CMS is meant to showcase Rapid Application Development with Symf\
 50 ony.</p>');
 51         $manager->persist($homemetaEN);
 52 
 53         $homemetaFR = new PageMeta();
 54         $homemetaFR->setPage($homepage);
 55         $homemetaFR->setMenuTitle('Accueil');
 56         $homemetaFR->setPageTitle('Bienvenue a SongBird CMS Démo');
 57         $homemetaFR->setShortDescription('Bienvenue a SongBird CMS Démo');
 58         $homemetaFR->setLocale('fr');
 59         $homemetaFR->setContent('<p>SongBird est un simple CMS construit avec de\
 60 s faisceaux populaires comme FOSUserBundle et SonataAdminBundle.
 61             Le CMS est destinée à mettre en valeur Rapid Application Development\
 62  avec Symfony .</p>');
 63         $manager->persist($homemetaFR);
 64 
 65         $aboutpage = new Page();
 66         $aboutpage->setSlug('about');
 67         $aboutpage->setIsPublished(1);
 68         $aboutpage->setSequence(1);
 69         $manager->persist($aboutpage);
 70 
 71         $aboutmetaEN = new PageMeta();
 72         $aboutmetaEN->setPage($aboutpage);
 73         $aboutmetaEN->setMenuTitle('About');
 74         $aboutmetaEN->setPageTitle('About SongBird');
 75         $aboutmetaEN->setShortDescription('What is Songbird?');
 76         $aboutmetaEN->setContent('<p>SongBird is a simple CMS (Content Managemen\
 77 t System) consisting the following features:</p>
 78         <ul>
 79         <li>Admin Panel and Dashboard – A password protected administration area\
 80  for administrators and users.</li>
 81         <li>User Management System – For administrators to manage the users of t\
 82 he site.</li>
 83         <li>Multi-lingual Capability – No CMS is complete without this.</li>
 84         <li>Page Management System – For managing the front-end pages of the sit\
 85 e.</li>
 86         <li>Media Management System – For administrators and users to manage fil\
 87 es and images.</li>
 88         <li>Frontend – The frontend of the website.</li>
 89         </ul>');
 90         $manager->persist($aboutmetaEN);
 91 
 92         $aboutmetaFR = new PageMeta();
 93         $aboutmetaFR->setPage($aboutpage);
 94         $aboutmetaFR->setLocale('fr');
 95         $aboutmetaFR->setMenuTitle('Sur');
 96         $aboutmetaFR->setPageTitle('Sur SongBird');
 97         $aboutmetaFR->setShortDescription('Qu\'est-ce que SongBird?');
 98         $aboutmetaFR->setContent('<p>SongBird est un simple CMS ( Content Manage\
 99 ment System ) comprenant les caractéristiques suivantes:</p>
100         <ul>
101         <li>Panneau d\'administration et Dashboard - Un mot de passe protégé esp\
102 ace d\'administration pour les administrateurs et les utilisateurs.</li>
103         <li>Système de gestion de l\'utilisateur - Pour les administrateurs de g\
104 érer les utilisateurs du site.</li>
105         <li>Capacité multilingue - Pas de CMS est complète sans cela.</li>
106         <li>Système de Management de la page - Pour gérer les pages du site fron\
107 taux.</li>
108         <li>Système de Gestion des médias - Pour les administrateurs et les util\
109 isateurs de gérer des fichiers et des images.</li>
110         <li>Frontend - L\'interface du site.</li>
111         </ul>');
112         $manager->persist($aboutmetaFR);
113 
114 
115         $whypage = new Page();
116         $whypage->setSlug('why_songbird');
117         $whypage->setIsPublished(1);
118         $whypage->setSequence(0);
119         $whypage->setParent($aboutpage);
120         $manager->persist($whypage);
121 
122         $whymetaEN = new PageMeta();
123         $whymetaEN->setPage($whypage);
124         $whymetaEN->setMenuTitle('Why Songbird');
125         $whymetaEN->setPageTitle('Why Songbird?');
126         $whymetaEN->setShortDescription('Why Another CMS?');
127         $whymetaEN->setContent('<p>Learning a modern day framework is not an eas\
128 y task. Songbird CMS does not aim to replace any existing CMS out there.
129         To put it simply, it is a play ground for people who wants to learn Symf\
130 ony by building a CMS from scratch.
131         Creating a semi-complex application like a CMS will give the coder insig\
132 hts in building bigger
133         things with a RAD framework like Symfony.</p>');
134         $manager->persist($whymetaEN);
135 
136         $whymetaFR = new PageMeta();
137         $whymetaFR->setPage($whypage);
138         $whymetaFR->setMenuTitle('pourquoi SongBird');
139         $whymetaFR->setPageTitle('pourquoi SongBird?');
140         $whymetaFR->setShortDescription('Pourquoi un autre CMS');
141         $whymetaFR->setContent('<p>Apprendre un cadre moderne est pas une tâche \
142 facile . Songbird CMS ne vise pas à remplacer tout CMS existant là-bas.
143         Pour dire les choses simplement , il est un terrain de jeu pour les gens\
144  qui veulent apprendre symfony en construisant un CMS à partir de zéro.
145         Création d\'une application semi- complexe comme un CMS donnera les idée\
146 s de codeur dans la construction de plus
147         les choses avec un cadre RAD comme Symfony</p>');
148         $whymetaFR->setLocale('fr');
149         $manager->persist($whymetaFR);
150 
151         $planpage = new Page();
152         $planpage->setSlug('documentation');
153         $planpage->setIsPublished(1);
154         $planpage->setSequence(1);
155         $planpage->setParent($aboutpage);
156         $manager->persist($planpage);
157 
158         $planmetaEn = new PageMeta();
159         $planmetaEn->setPage($planpage);
160         $planmetaEn->setMenuTitle('Where do I start');
161         $planmetaEn->setPageTitle('Where do I start?');
162         $planmetaEn->setShortDescription('Where Do I Start?');
163         $planmetaEn->setContent('<p>I recommend reading the online documentation\
164  at <a href="https://leanpub.com/practicalsymfony3">leanpub</a></p>
165             <p>git clone the repo. Read and Code at the same time. I believe tha\
166 t is the most effective way to learn.</p>');
167         $manager->persist($planmetaEn);
168 
169         $planmetaFR = new PageMeta();
170         $planmetaFR->setPage($planpage);
171         $planmetaFR->setLocale('fr');
172         $planmetaFR->setMenuTitle('Où est-ce que je commence');
173         $planmetaFR->setPageTitle('Où est-ce que je commence?');
174         $planmetaFR->setShortDescription('Où est-ce que je commence?');
175         $planmetaFR->setContent('<p>Je recommande la lecture de la documentation\
176  en ligne à <a href="https://leanpub.com/practicalsymfony3">leanpub</a></p>
177             <p>git clone the repo. Lire et code en même temps . Je crois que la \
178 façon la plus efficace d\'apprendre.</p>');
179         $manager->persist($planmetaFR);
180 
181         $contactpage = new Page();
182         $contactpage->setSlug('contact_us');
183         $contactpage->setIsPublished(1);
184         $contactpage->setSequence(2);
185         $manager->persist($contactpage);
186 
187         $contactmetaEN = new PageMeta();
188         $contactmetaEN->setPage($contactpage);
189         $contactmetaEN->setPageTitle('Contact Us');
190         $contactmetaEN->setMenuTitle('Contact');
191         $contactmetaEN->setShortDescription('Contact');
192         $contactmetaEN->setContent('<p>I hope Songbird can be beneficial to anyo\
193 ne who aspires to learn Symfony.</p>
194             <p>This project is hosted in <a href="https://github.com/bernardpeh/\
195 songbird" target="_blank">github</a>.</p>
196             <p>To make this CMS a better learning platform for everyone, feel fr\
197 ee to update the code and create a pull request in github.</p>');
198         $manager->persist($contactmetaEN);
199 
200         $contactmetaFR = new PageMeta();
201         $contactmetaFR->setPage($contactpage);
202         $contactmetaFR->setLocale('fr');
203         $contactmetaFR->setPageTitle('Contactez nous');
204         $contactmetaFR->setMenuTitle('Contact');
205         $contactmetaFR->setShortDescription('Contact');
206         $contactmetaFR->setContent('<p>Je l\'espère Songbird peut être bénéfique\
207  pour toute personne qui aspire à apprendre symfony.</p>
208             <p>Ce projet est hébergé dans <a href="https://github.com/bernardpeh\
209 /songbird" target="_blank">github</a>.</p>
210             <p>Pour faire ce CMS une meilleure plateforme d\'apprentissage pour \
211 tout le monde , vous pouvez mettre à jour le code et créer une demande de tracti\
212 on dans github.</p>');
213         $manager->persist($contactmetaFR);
214 
215         // now save all
216         $manager->flush();
217     }
218 
219 }

reset the app to load the fixtures and check that the entries have been added to the db.

1 -> ./scripts/resetapp

Now go to the page url and you should see the default crud template

1 http://songbird.app:8000/app_dev.php/songbird_page/

Everything is looking plain at the moment, let us integrate nestablejs.

Integrating NestableJS

How do we integrate NestableJS to our bundle? The secret will be in the Page Controller. We will change the logic there.

  1 # src/Songbird/NestablePageBundle/Controller/PageController.php
  2 
  3 namespace Songbird\NestablePageBundle\Controller;
  4 
  5 use Songbird\NestablePageBundle\Entity\Page;
  6 use Symfony\Bundle\FrameworkBundle\Controller\Controller;
  7 use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
  8 use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
  9 use Symfony\Component\HttpFoundation\Request;
 10 use Symfony\Component\HttpFoundation\JsonResponse;
 11 
 12 /**
 13  * Page controller.
 14  *
 15  * @Route("/songbird_page")
 16  */
 17 class PageController extends Controller
 18 {
 19 
 20     /**
 21      * Lists all Page entities.
 22      *
 23      * @Route("/", name="songbird_page_index")
 24      * @Method("GET")
 25      */
 26     public function indexAction()
 27     {
 28         return $this->redirect($this->generateUrl('songbird_page_list'));
 29     }
 30 
 31     /**
 32      * Lists all nested page
 33      *
 34      * @Route("/list", name="songbird_page_list")
 35      * @Method("GET")
 36      */
 37     public function listAction()
 38     {
 39         $em = $this->getDoctrine()->getManager();
 40         $rootMenuItems = $em->getRepository('SongbirdNestablePageBundle:Page')->\
 41 findParent();
 42 
 43         return $this->render('page/list.html.twig', array(
 44             'tree' => $rootMenuItems,
 45         ));
 46     }
 47 
 48     /**
 49      * reorder pages
 50      *
 51      * @Route("/reorder", name="songbird_page_reorder")
 52      * @Method("POST")
 53      */
 54     public function reorderAction(Request $request)
 55     {
 56         $em = $this->getDoctrine()->getManager();
 57         // id of affected element
 58         $id = $request->get('id');
 59         // parent Id
 60         $parentId = ($request->get('parentId') == '') ? null : $request->get('pa\
 61 rentId');
 62         // new sequence of this element. 0 means first element.
 63         $position = $request->get('position');
 64 
 65         $result = $em->getRepository('SongbirdNestablePageBundle:Page')->reorder\
 66 Element($id, $parentId, $position);
 67 
 68         return new JsonResponse(
 69             array('message' => $this->get('translator')->trans($result[0], array\
 70 (), 'SongbirdNestablePageBundle')
 71             , 'success' => $result[1])
 72         );
 73     }
 74 
 75     /**
 76      * Creates a new Page entity.
 77      *
 78      * @Route("/new", name="songbird_page_new")
 79      * @Method({"GET", "POST"})
 80      */
 81     public function newAction(Request $request)
 82     {
 83         $page = new Page();
 84         $form = $this->createForm('Songbird\NestablePageBundle\Form\PageType', $\
 85 page);
 86         $form->handleRequest($request);
 87 
 88         if ($form->isSubmitted() && $form->isValid()) {
 89             $em = $this->getDoctrine()->getManager();
 90             $em->persist($page);
 91             $em->flush();
 92 
 93             return $this->redirectToRoute('songbird_page_show', array('id' => $p\
 94 age->getId()));
 95         }
 96 
 97         return $this->render('page/new.html.twig', array(
 98             'page' => $page,
 99             'form' => $form->createView(),
100         ));
101     }
102 
103     /**
104      * Finds and displays a Page entity.
105      *
106      * @Route("/{id}", name="songbird_page_show")
107      * @Method("GET")
108      */
109     public function showAction(Request $request, Page $page)
110     {
111         $em = $this->getDoctrine()->getManager();
112 
113         $pageMeta = $em->getRepository('SongbirdNestablePageBundle:PageMeta')->f\
114 indPageMetaByLocale($page,$request->getLocale());
115 
116         $deleteForm = $this->createDeleteForm($page);
117 
118         return $this->render('page/show.html.twig', array(
119             'page' => $page,
120             'pageMeta' => $pageMeta,
121             'delete_form' => $deleteForm->createView(),
122         ));
123     }
124 
125     /**
126      * Displays a form to edit an existing Page entity.
127      *
128      * @Route("/{id}/edit", name="songbird_page_edit")
129      * @Method({"GET", "POST"})
130      */
131     public function editAction(Request $request, Page $page)
132     {
133         $deleteForm = $this->createDeleteForm($page);
134         $editForm = $this->createForm('Songbird\NestablePageBundle\Form\PageType\
135 ', $page);
136         $editForm->handleRequest($request);
137 
138         if ($editForm->isSubmitted() && $editForm->isValid()) {
139             $em = $this->getDoctrine()->getManager();
140             $em->persist($page);
141             $em->flush();
142 
143             return $this->redirectToRoute('songbird_page_edit', array('id' => $p\
144 age->getId()));
145         }
146 
147         return $this->render('page/edit.html.twig', array(
148             'page' => $page,
149             'edit_form' => $editForm->createView(),
150             'delete_form' => $deleteForm->createView(),
151         ));
152     }
153 
154     /**
155      * Deletes a Page entity.
156      *
157      * @Route("/{id}", name="songbird_page_delete")
158      * @Method("DELETE")
159      */
160     public function deleteAction(Request $request, Page $page)
161     {
162         $form = $this->createDeleteForm($page);
163         $form->handleRequest($request);
164 
165         if ($form->isSubmitted() && $form->isValid()) {
166             $em = $this->getDoctrine()->getManager();
167             $em->remove($page);
168             $em->flush();
169         }
170 
171         return $this->redirectToRoute('songbird_page_index');
172     }
173 
174     /**
175      * Creates a form to delete a Page entity.
176      *
177      * @param Page $page The Page entity
178      *
179      * @return \Symfony\Component\Form\Form The form
180      */
181     private function createDeleteForm(Page $page)
182     {
183         return $this->createFormBuilder()
184             ->setAction($this->generateUrl('songbird_page_delete', array('id' =>\
185  $page->getId())))
186             ->setMethod('DELETE')
187             ->getForm()
188             ;
189     }
190 }

We have added 2 extra methods, listAction and reorderAction. As the controller should have minimum logic, we have moved the bulk of reorderAction logic to the repository.

  1 # src/Songbird/NestablePageBundle/Entity/PageRepository.php
  2 ...
  3     /**
  4      * reorder element based on user input
  5      * @param  int $id       id of element dragged
  6      * @param  int $parentId parent id
  7      * @param  int $position new position relative to parent id. 0 is first posi\
  8 tion
  9      * @return array          array([string] message, [boolean] success)
 10      */
 11     public function reorderElement($id, $parentId, $position)
 12     {
 13         // step 1: get all siblings based on old location. update the seq
 14         $old_item = $this->findOneById($id);
 15 
 16         if ($old_item === null) {
 17             $old_parent_id = '';
 18         }
 19         else {
 20             $old_parent_id = ($old_item->getParent() === null) ? '' : $old_item-\
 21 >getParent()->getId();
 22         }
 23 
 24 
 25         // if old parent and new parent is the same, user moving in same level.
 26         // dont need to update old parent
 27         if ($old_parent_id != $parentId) {
 28             $old_children = $this->findBy(
 29                 array('parent' => $old_parent_id),
 30                 array('sequence' => 'ASC')
 31                 );
 32             $seq = 0;
 33 
 34             foreach ($old_children as $oc) {
 35                 $or = $this->findOneById($oc->getId());
 36                 if ($old_item->getSequence() != $or->getSequence()) {
 37                     $or->setSequence($seq);
 38                     $this->getEntityManager()->persist($or);
 39                     $seq++;
 40                 }
 41             }
 42         }
 43 
 44         $new_children = $this->findBy(
 45             array('parent' => $parentId),
 46             array('sequence' => 'ASC')
 47             );
 48         $seq = 0;
 49 
 50         $ir = $this->findOneById($id);
 51 
 52 
 53         if (!is_null($parentId)) {
 54             $parent = $this->findOneById($parentId);
 55             if ($ir !== null) {
 56                 $ir->setParent($parent);
 57             }
 58         }
 59         else {
 60             if ($ir !== null) {
 61                 $ir->setParent();
 62             }
 63         }
 64         foreach ($new_children as $nc) {
 65             // if the id is the same, it means user moves in same level
 66 
 67             if ($old_parent_id == $parentId) {
 68                 // if in same level, we just need to swap position
 69                 // get id of element with the current position then swap it
 70                 $nr = $this->findBy(
 71                     array('sequence' => $position, 'parent' => $parentId)
 72                     );
 73 
 74                 $nr[0]->setSequence($ir->getSequence());
 75                 $this->getEntityManager()->persist($nr[0]);
 76                 $ir->setSequence($position);
 77                 $this->getEntityManager()->persist($ir);
 78                 break;
 79             }
 80             // user drag from one level to the next, it is a new addition
 81             else {
 82 
 83                 if ($position == $seq) {
 84                     $ir->setSequence($seq);
 85                     $this->getEntityManager()->persist($ir);
 86                     $seq++;
 87                 }
 88 
 89                 $nr = $this->findOneById($nc->getId());
 90                 $nr->setSequence($seq);
 91                 $this->getEntityManager()->persist($nr);
 92 
 93             }
 94 
 95             $seq++;
 96         }
 97 
 98         // if its the last entry and user moved to new level
 99         if ($old_parent_id != $parentId && $position == count($new_children)) {
100             $ir->setSequence($seq);
101             $this->getEntityManager()->persist($ir);
102         }
103 
104         $message = '';
105         $success = true;
106 
107         // step 3: run a loop, insert the new element and update the seq
108         try {
109             $this->getEntityManager()->flush();
110             $this->getEntityManager()->clear(); // prevent doctrine from caching
111             $message = 'flash_reorder_edit_success';
112         }
113         catch (\Exception $e) {
114             // $message = $e->getMessage();
115             $message = 'Cannot reorder element.';
116             $success = false;
117         }
118 
119         return array($message, $success);
120     }
121 ...

We need a custom query to get the pagemeta based on locale.

 1 # src/Songbird/NestablePageBundle/Entity/PageMetaRepository.php
 2 
 3 namespace Songbird\NestablePageBundle\Repository;
 4 
 5 use Songbird\NestablePageBundle\Entity\Page;
 6 
 7 /**
 8  * PageMetaRepository
 9  *
10  * This class was generated by the Doctrine ORM. Add your own custom
11  * repository methods below.
12  */
13 class PageMetaRepository extends \Doctrine\ORM\EntityRepository
14 {
15 	/**
16 	 * @param Page $page
17 	 * @param $locale
18 	 *
19 	 * @return PageMeta
20 	 */
21 	 public function findPageMetaByLocale(Page $page, $locale) {
22 
23 		$query = $this->createQueryBuilder('pm')
24 		              ->where('pm.locale = :locale')
25 		              ->andWhere('pm.page = :page')
26 		              ->setParameter('locale', $locale)
27 		              ->setParameter('page', $page)
28 		              ->getQuery();
29 
30 		return $query->getOneOrNullResult();
31 
32 	}
33 }

We then remove the created and modified date from the form as these fields should not be editable.

 1 # src/Songbird/NestablePageBundle/Form/PageType.php
 2 
 3 namespace Songbird\NestablePageBundle\Form;
 4 
 5 use Symfony\Component\Form\AbstractType;
 6 use Symfony\Component\Form\FormBuilderInterface;
 7 use Symfony\Component\OptionsResolver\OptionsResolverInterface;
 8 
 9 class PageType extends AbstractType
10 {
11     /**
12      * @param FormBuilderInterface $builder
13      * @param array $options
14      */
15     public function buildForm(FormBuilderInterface $builder, array $options)
16     {
17         $builder
18             ->add('slug')
19             ->add('isPublished')
20             ->add('sequence')
21             ->add('parent')
22         ;
23     }
24 
25     /**
26      * @param OptionsResolver $resolver
27      */
28     public function configureOptions(OptionsResolver $resolver)
29     {
30         $resolver->setDefaults(array(
31             'data_class' => 'Songbird\NestablePageBundle\Entity\Page'
32         ));
33     }
34 }

and we will leave the PageMetaController.php as default.

Now, we need to make changes to the view - list.html.twig.

 1 # app/Resources/views/page/list.html.twig
 2 {% extends '::base.html.twig' %}
 3 
 4 {% block stylesheets %}
 5 	{{ parent() }}
 6 	<link rel="stylesheet" href="{{ asset('bundles/songbirdnestablepage/css/styles.\
 7 css ') }}">
 8 {% endblock %}
 9 
10 {% block body -%}
11 	<div class="alert alert-dismissable">
12 		{{ 'flash_reorder_instructions' | trans({}, 'SongbirdNestablePageBundle') }}
13 	</div>
14 
15 	{% block main %}
16 	    <button type="button" onclick="$('.dd').nestable('expandAll')">Expand All</\
17 button>
18 	    <button type="button" onclick="$('.dd').nestable('collapseAll')">Collapse A\
19 ll</button>
20 	    <div id="nestable" class="dd">
21 	        <ol class="dd-list">
22 	            {% include "page/tree.html.twig" with { 'tree':tree } %}
23 	        </ol>
24 	    </div>
25 	{% endblock %}
26         <ul class="record_actions">
27             <li>
28                 <a href="{{ path('songbird_page_new') }}">
29                     Create New Page
30                 </a>
31             </li>
32             <li>
33                 <a href="{{ path('songbird_pagemeta_new') }}">
34                     Create New PageMeta
35                 </a>
36             </li>
37         </ul>
38 {% endblock %}
39 
40 {% block script %}
41     {{ parent() }}
42     <script src="{{ asset('bundles/songbirdnestablepage/js/jquery-1.11.3.min.js'\
43 ) }}"></script>
44     <script src="{{ asset('bundles/songbirdnestablepage/js/jquery.nestable.js') \
45 }}"></script>
46     <script>
47 
48     $(function() {
49 
50 			var before = null, after = null;
51 
52 			$('.dd').nestable({
53 				afterInit: function ( event ) { }
54 			});
55 
56         $('.dd').nestable('collapseAll');
57         before = JSON.stringify($('.dd').nestable('serialize'));
58         $('.dd').on('dragEnd', function(event, item, source, destination, positi\
59 on) {
60 
61 					id = item.attr('data-id');
62 					parentId = item.closest('li').parent().closest('li').attr('data-id');
63 
64 					// if parent id is null of if parent id and id is the same, it is the top l\
65 evel.
66 					parentId = (parentId == id || typeof(parentId)  === "undefined") ?  '' : pa\
67 rentId;
68 
69 					after = JSON.stringify($('.dd').nestable('serialize'));
70 
71 	        if (before != after) {
72 	            $.ajax({
73 	                type: "POST",
74 	                url: "{{ path('songbird_page_reorder') }}",
75 	                data: {id: id, parentId: parentId, position: position},
76 	                success: function (data, dataType) {
77 										if (data.success) {
78 											$('.alert').addClass('alert-success');
79 										}
80 										else {
81 											$('.alert').addClass('alert-danger');
82 										}
83 										$('.alert').html(data.message);
84 										$('.alert').fadeTo( 0 , 1, function() {});
85 										$('.alert').fadeTo( 4000 , 0, function() {});
86 	                },
87 
88 	                error: function (XMLHttpRequest, textStatus, errorThrown) {
89 	                    console.log(XMLHttpRequest);
90 	                }
91 	            });
92 	            before = after;
93 	        }
94 	    });
95     });
96     </script>
97 {% endblock %}

and tree.html.twig

 1 # app/Resources/views/page/tree.html.twig
 2 {% for v in tree %}
 3     <li class='dd-item' data-id='{{ v.getId() }}'>
 4         <div class='dd-handle'>
 5             <a class="dd-nodrag" href="{{ path('songbird_page_show', {id: v.getI\
 6 d()}) }}">{{ v.getSlug() }}</a>
 7         </div>
 8 
 9         {% set children = v.getChildren()|length %}
10         {% if children > 0 %}
11             <ol class='dd-list'>
12                 {% include "page/tree.html.twig" with { 'tree':v.getChildren() }\
13  %}
14             </ol>
15         {% endif %}
16     </li>
17 {% endfor %}

We need to update the show.html.twig to allow user to view pagemeta.

 1 # app/Resources/views/pagemeta/show.html.twig
 2 
 3 ...
 4 <ul>
 5         <li>
 6             <a href="{{ path('songbird_page_index') }}">Back to the list</a>
 7         </li>
 8         <li>
 9             <a href="{{ path('songbird_page_edit', { 'id': page.id }) }}">Edit P\
10 age</a>
11         </li>
12         <li>
13             <a href="{{ path('songbird_pagemeta_show', { 'id': pageMeta.id }) }}\
14 ">View PageMeta</a>
15         </li>
16         <li>
17             <a href="{{ path('songbird_pagemeta_edit', { 'id': pageMeta.id }) }}\
18 ">Edit PageMeta</a>
19         </li>
20         <li>
21             {{ form_start(delete_form) }}
22                 <input type="submit" value="Delete">
23             {{ form_end(delete_form) }}
24         </li>
25 </ul>

The rest of the view templates can use the defaults. Ready to test the bundle?

1 -> ./scripts/resetapp
2 # remember gulp? the assets:install command is in there.
3 -> gulp

Now go to the page index and try reorder the menu.

1 http://songbird.app:8000/app_dev.php/songbird_page/

Create Functional Tests (Optional)

Sticking to the industrial standard, we are going to use PHPUnit rather than codeception. The main reason for doing that is to remove dependency on codeception. The only downside is that we could not simulate real browser interaction with the app.

Let us install phpunit using composer

1 -> ./scripts/composer require --dev phpunit/phpunit ^5.7

We need to call phpunit in docker, so we need another wrapper for it.

1 -> touch scripts/phpunit
2 -> chmod u+x scripts/phpunit
1 # in scripts/phpunit
2 
3 #!/bin/bash
4 docker-compose exec php vendor/phpunit/phpunit/phpunit $@ 

Then, let us create the functional tests based on the user stories.

phpunit uses the phpunit.xml.dist under the symfony dir. To run the test, simply run this command in the symfony dir

1 # in symfony dir
2 -> ./scripts/phpunit

let us run testListPages function within PageControllerTest.php for example,

1 -> ./scripts/phpunit --filter testListPages src/Songbird/NestablePageBundle/Test\
2 s/Controller/PageControllerTest.php

You should get a “no tests executed” error because we haven’t write the test. Let us write the test.

  1 # src/Songbird/NestablePageBundle/Tests/Controller/PageControllerTest.php
  2 
  3 namespace Songbird\NestablePageBundle\Tests\Controller;
  4 
  5 use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
  6 use Symfony\Component\Console\Input\StringInput;
  7 use Symfony\Bundle\FrameworkBundle\Console\Application;
  8 
  9 /**
 10  * As test2 user
 11  * I WANT to manage pages
 12  * SO THAT I can update them anytime
 13  *
 14  * Class PageControllerTest
 15  * @package Songbird\NestablePageBundle\Tests\Controller
 16  */
 17 class PageControllerTest extends WebTestCase
 18 {
 19     protected static $application;
 20 
 21     protected function setUp()
 22     {
 23         self::getApplication()->run(new StringInput('doctrine:database:drop --fo\
 24 rce'));
 25         self::getApplication()->run(new StringInput('doctrine:database:create'));
 26         self::getApplication()->run(new StringInput('doctrine:schema:create'));
 27         self::getApplication()->run(new StringInput('doctrine:fixtures:load -n')\
 28 );
 29     }
 30 
 31     protected static function getApplication()
 32     {
 33         if (null === self::$application) {
 34             $client = static::createClient();
 35 
 36             self::$application = new Application($client->getKernel());
 37             self::$application->setAutoExit(false);
 38         }
 39 
 40         return self::$application;
 41     }
 42 
 43     /**
 44      * GIVEN List Pages
 45      * WHEN I go to /songbird_page
 46      * THEN I should see the why_songbird slug under the about slug
 47      *
 48      * scenario 17.11
 49      *
 50      * Test list action
 51      */
 52     public function testListPages()
 53     {
 54         $client = static::createClient();
 55 
 56         $crawler = $client->request('GET', '/songbird_page/list');
 57         // i should see why_songbird text
 58         $this->assertContains(
 59             'why_songbird',
 60             $client->getResponse()->getContent()
 61         );
 62         // there should be 3 parent menus
 63         $nodes = $crawler->filterXPath('//div[@id="nestable"]/ol');
 64         $this->assertEquals(count($nodes->children()), 3);
 65 
 66         // there should be 2 entries under the about menu
 67         $nodes = $crawler->filterXPath('//li[@data-id="2"]/ol');
 68         $this->assertEquals(count($nodes->children()), 2);
 69     }
 70 
 71     /**
 72      * GIVEN Show contact us page
 73      * WHEN I go to /songbird_page/5
 74      * THEN I should see the word "contact_us" and the word "Created"
 75      *
 76      * scenario 17.12
 77      *
 78      * Test show action
 79      */
 80     public function testShowContactUsPage()
 81     {
 82         $client = static::createClient();
 83         // go to main listing page
 84         $crawler = $client->request('GET', '/songbird_page/list');
 85         // click on contact_us link
 86         $crawler = $client->click($crawler->selectLink('contact_us')->link());
 87 
 88         // i should see "contact_us"
 89         $this->assertContains(
 90             'contact_us',
 91             $client->getResponse()->getContent()
 92         );
 93 
 94         // i should see "Created"
 95         $this->assertContains(
 96             'Created',
 97             $client->getResponse()->getContent()
 98         );
 99     }
100 
101     /**
102      * GIVEN Reorder home
103      * WHEN I simulate a drag and drop of the home menu to under the about menu \
104 and submit the post data to /songbird_page/reorder
105      * THEN I should see "reordered successfully message" in the response and me\
106 nus should be updated
107      *
108      * scenario 17.13
109      *
110      * We simulate ajax submission by reordering menu
111      */
112     public function testReorderHomePage()
113     {
114         $client = static::createClient();
115 
116         // home is dragged under about and in the second position
117         $crawler = $client->request(
118             'POST',
119             '/songbird_page/reorder',
120             array(
121                 'id' => 1,
122                 'parentId' => 2,
123                 'position' => 1
124             ),
125             array(),
126             array('HTTP_X-Requested-With' => 'XMLHttpRequest')
127         );
128 
129         // i should get a success message in the returned content
130         $this->assertContains(
131             'menu has been reordered successfully',
132             $client->getResponse()->getContent()
133         );
134 
135         // go back to page list again
136         $crawler = $client->request('GET', '/songbird_page/list');
137         // there should be 2 parent menus
138         $nodes = $crawler->filterXPath('//div[@id="nestable"]/ol');
139         $this->assertEquals(count($nodes->children()), 2);
140         // there should 3 items under the about menu
141         $nodes = $crawler->filterXPath('//li[@data-id="2"]/ol');
142         $this->assertEquals(count($nodes->children()), 3);
143     }
144 
145     /**
146      * GIVEN Edit home page meta
147      * WHEN I go to edit homepage url and update the menu title of "Home" to "Ho\
148 me1" and click update
149      * THEN I should see the text "successfully updated" message
150      *
151      * scenario 17.14
152      *
153      * Test edit action
154      */
155     public function testEditHomePage()
156     {
157         $client = static::createClient();
158 
159         $crawler = $client->request('GET', '/songbird_page/1/edit');
160 
161         $form = $crawler->selectButton('Edit')->form(array(
162             'songbird_nestablepagebundle_page[slug]'  => 'home1',
163         ));
164 
165         $client->submit($form);
166 
167         // go back to the list again and i should see the slug updated
168         $crawler = $client->request('GET', '/songbird_page/list');
169         $this->assertContains(
170             'home1',
171             $client->getResponse()->getContent()
172         );
173     }
174 
175     /**
176      * GIVEN Create and delete test pagemeta
177      * WHEN go to /new and fill in details and click "Create" button, then go to\
178  test page and click add new meta and fill in the details and click "create" but\
179 ton, then click delete button
180      * THEN I should see the new page and pagemeta being created and pagemeta de\
181 leted
182      *
183      * scenario 17.15
184      *
185      * Test new and delete action
186      */
187     public function testCreateDeleteTestPage()
188     {
189         $client = static::createClient();
190 
191         $crawler = $client->request('GET', '/songbird_page/new');
192 
193         $form = $crawler->selectButton('Create')->form(array(
194             'songbird_nestablepagebundle_page[slug]'  => 'test_page',
195             'songbird_nestablepagebundle_page[isPublished]'  => true,
196             'songbird_nestablepagebundle_page[sequence]'  => 1,
197             'songbird_nestablepagebundle_page[parent]'  => 2,
198         ));
199 
200         $client->submit($form);
201 
202         // go back to the list again and i should see the slug updated
203         $crawler = $client->request('GET', '/songbird_page/list');
204         $this->assertContains(
205             'test_page',
206             $client->getResponse()->getContent()
207         );
208 
209         $crawler = $client->click($crawler->selectLink('Create New PageMeta')->l\
210 ink());
211         // at create new pagemeta page. new test_page is id 6
212         $form = $crawler->selectButton('Create')->form(array(
213             'songbird_nestablepagebundle_pagemeta[page_title]'  => 'test page ti\
214 tle',
215             'songbird_nestablepagebundle_pagemeta[menu_title]'  => 'test menu ti\
216 tle',
217             'songbird_nestablepagebundle_pagemeta[short_description]'  => 'short\
218  content',
219             'songbird_nestablepagebundle_pagemeta[content]'  => 'long content',
220             'songbird_nestablepagebundle_pagemeta[page]'  => 6,
221         ));
222 
223         $crawler = $client->submit($form);
224 
225         // follow redirect to show pagemeta
226         $crawler = $client->followRedirect();
227 
228         $this->assertContains(
229             'short content',
230             $client->getResponse()->getContent()
231         );
232 
233         // at show pagemeta, click delete
234         $form = $crawler->selectButton('Delete')->form();
235         $crawler = $client->submit($form);
236 
237         // go back to the pagemeta list again and i should NOT see the test_page\
238  anymore
239         $crawler = $client->request('GET', '/songbird_pagemeta');
240 
241         $this->assertNotContains(
242             'test page title',
243             $client->getResponse()->getContent()
244         );
245     }
246 
247     /**
248      * GIVEN Delete contact us page
249      * WHEN go to /songbird_page/5 and click "Delete" button
250      * THEN I should see the contact_us slug no longer available in the listing \
251 page. Page id 5 should no longer be found in the pagemeta table
252      *
253      * scenario 17.16
254      */
255     public function testDeleteContactUsPage()
256     {
257         $client = static::createClient();
258         // now if we remove contact_us page, ie id 5, all its page meta should b\
259 e deleted
260         $crawler = $client->request('GET', '/songbird_page/5');
261         $form = $crawler->selectButton('Delete')->form();
262         $crawler = $client->submit($form);
263         $crawler = $client->followRedirect();
264 
265         $this->assertNotContains(
266             'contact_us',
267             $client->getResponse()->getContent()
268         );
269 
270         // we now connect to do and make sure the related pagemetas are no longe\
271 r in the pagemeta table.
272         $res = $client->getContainer()->get('doctrine')->getRepository('Songbird\
273 NestablePageBundle:PageMeta')->findByPage(5);
274         $this->assertEquals(0, count($res));
275     }
276 
277 }

As we are testing both page and pagemeta controller at the same time, we can remove the pagemeta controller test.

1 -> rm src/Songbird/NestablePageBundle/Tests/Controller/PageMetaControllerTest.php

lets run the test again and make sure everything is ok

 1 -> ./scripts/phpunit src/Songbird/NestablePageBundle
 2 Creating database schema...
 3 Database schema created successfully!
 4   > purging database
 5   > loading Songbird\NestablepageBundle\DataFixtures\ORM\LoadPageData
 6   > loading [1] AppBundle\DataFixtures\ORM\LoadUserData
 7   > loading [2] AppBundle\DataFixtures\ORM\LoadMediaData
 8 ...
 9 Time: 30.71 seconds, Memory: 70.75Mb
10 
11 OK (6 tests, 14 assertions)

You might have noticed that the phpunit functional tests seemed to run much faster than codeception acceptance tests. Why? Does that makes it more attractive to you?

Whatever we do in this chapter should not affect what we have done previously. To verify that this is indeed the case,

1 -> ./scripts/runtest

Remember to commit all the code before moving on.

Summary

In this chapter, we have created our own page bundle and generated CRUD in a quick way using the command line. We have also customised the listing page and created a draggable menu using the jquery nestable menu. Data is submitted to the backend via ajax and updated dynamically.

Exercises

  • Are there any benefits of creating a page bundle that has no dependency on Symfony at all? How would you do it? (Optional)
  • KnpmenuBundle is a popular bundle for handling menus. How would you integrate it with SongbirdNestableMenu? (Optional)

References

Chapter 18: Making Your Bundle Reusable

We have created a page bundle in the previous chapter using the default way. It’s not perfect if you want to share it with everyone. How do we do that? Be warned, we need lots of refactoring in the code to make it sharable.

This is a long chapter. Its is a good process to go through because it makes you pause and think. If you already know the process and want to skip through, simple clone the NestablePageBundle from github and follow the installation instructions in the readme file. Then, jump over to the next chapter.

Creating a separate repository

First of all, let us create a readme file.

1 -> cd src/Songbird/NestablePageBundle
2 -> touch readme.md

Update the readme file.

Let us create the composer.json file for this repo. We will do a simple one

1 -> composer init

Follow the prompts. You might need to read up on software licensing. MIT license is becoming really popular. The sample composer.json might look like this:

 1 {
 2     "name": "Yourname/nestable-page-bundle",
 3     "description": "your description",
 4     "type": "symfony-bundle",
 5     "require": {
 6         "symfony/symfony": "~3.0"
 7     },
 8     "require-dev": {
 9         "doctrine/doctrine-fixtures-bundle": "~2.0"
10     },
11     "autoload": {
12         "psr-4": { "Songbird\NestablePageBundle\": "" }
13     },
14     "license": "MIT",
15     "authors": [
16         {
17             "name": "your name",
18             "email": "your_email@your_email.xx"
19         }
20     ]
21 }

Note that we have to add the “autoload” component so that Symfony can autoload the namespace post installation. PS-4 is the default standard at the time of writing. Next, let us create the license in a text file

1 -> touch LICENSE

copy the MIT LICENSE and update the LICENSE file.

Init the repo

1 -> cd src/Songbird/NestablePageBundle
2 -> git init .
3 -> git add .
4 -> git commit -m"init commit"

In github (create a new acct if not done), create a new repo. Let’s call it NestablePageBundle for example. Once you have created the new repo, you should see instructions on how to push your code.

1 -> git remote add origin git@github.com:your_username/NestablePageBundle.git
2 -> git push -u origin master

Let us give our first release a version number using the semantic versioning convention.

1 -> git tag 0.1.0
2 -> git push --tags

Your repository is now available for the public to pull.

Updating Application composer.json

If we add our repo to packagist, we could install our bundle like any other bundles using the “composer require” command. Anyone reading this tutorial might submit their test bundle to packagist, so I thought it would be a better idea to install the bundle from git instead. Let’s use github for the sake of illustration.

 1 # composer.json
 2 ...
 3     "repositories": [
 4         {
 5             "type": "git",
 6             "url": "https://github.com/your_name/NestablePageBundle"
 7         }
 8     ],
 9 ...
10     "require": {
11         ...
12         "your_name/nestable-page-bundle": ">0.1.0"
13     }
14 ...

Note that the bundle name is “nestable-page-bundle” under the “require” section. Why not use NestablePageBundle following Symfony’s convention? Remember the composer.json file that you have created previously? “nestable-page-bundle” is the name of the bundle as specified in that composer file.

Now lets run composer update and see what happens

1 -> cd ../../../
2 -> composer update
3 ...
4 
5   - Installing your_name/nestable-page-bundle (0.1.0)
6     Downloading: 100%

At this point, look at the vendor directory and you will see your bundle being installed in there. That’s a good start.

Renaming SongbirdNestablePageBundle

Let us do some cleaning up. We no longer need the src/Songbird/NestablePageBundle since we have installed the bundle under vendor dir.

1 git rm -rf src/Songbird/
2 git rm -rf app/Resources/views/{page,pagemeta}

Let us check if the route is still there.

1 -> ./scripts/console debug:router | grep songbird
2 ...
3 songbird_page            GET      ANY    ANY  /songbird_page/
4 songbird_page_list       GET      ANY    ANY  /songbird_page/list
5 songbird_page_reorder    POST     ANY    ANY  /songbird_page/reorder

Woah!! We have already deleted src/Songbird/NestablePageBundle and we should expect to see some errors. Why are the songbird routes still there?

We have a problem. The namespace “Songbird” is no longer relevant in vendor/your-name/nestable-page-bundle since the bundle is already decoupled from Songbird CMS. We want to change the bundle’s filename and namespace so that it is more intuitive. How do we do that?

Let us re-download the repo and do some mass restructuring

1 -> cd vendor/your-name
2 -> rm -rf nestable-page-bundle
3 -> git clone git@github.com:your_name/NestablePageBundle.git nestable-page-bundle
4 -> cd nestable-page-bundle

There is no quick way for this, some bash magic helps

 1 # Your-Initial can be something short but has to be unique
 2 # let us change the namespace
 3 -> find . -type f | grep -v .git/ | while read s; do sed -i '' 's/Songbird\Nesta\
 4 blePageBundle/{your-initial}\NestablePageBundle/g' $s ; done
 5 # change the bundle name
 6 -> find . -type f | grep -v .git/ | while read s; do sed -i '' 's/SongbirdNestab\
 7 lePage/{your-initial}NestablePage/g' $s ; done
 8 -> find . -type f | grep -v .git/ | while read s; do sed -i '' 's/songbird_/{you\
 9 r_initial}_/g' $s ; done
10 -> find . -type f | grep -v .git/ | while read s; do sed -i '' 's/songbirdnestab\
11 le/{your_initial}nestable/g' $s ; done

That should save us 90% of the time. Then visually walk through all the files and rename whatever that was not renamed by the bash commands.

Lastly, rename the bundle file

1 -> git mv SongbirdNestablePageBundle.php {your-initial}NestablePageBundle.php
2 -> cd DependencyInjection
3 -> git mv SongbirdNestablePageExtension.php BpehNestablePageExtension.php
4 -> cd ../Resources/translations
5 -> git mv SongbirdNestablePageBundle.en.xlf {your-initial}NestablePageBundle.en.\
6 xlf
7 -> git mv SongbirdNestablePageBundle.fr.xlf {your-initial}NestablePageBundle.fr.\
8 xlf

Now, here is the question. How do we test our changes without committing to git and re-run composer update? We can update our entry in vendor/composer/autoload_psr4.php

1 # vendor/composer/autoload_psr4.php
2 ...
3     # 'Songbird\NestablePageBundle\' => array($vendorDir . '/{your-name}/nestabl\
4 e-page-bundle'),
5     '{your-initial}\NestablePageBundle\' => array($vendorDir . '/{your-name}/nes\
6 table-page-bundle'),
7 ...

Now, let us update AppKernel

1 # app/config/AppKernel.php
2 ...
3 # new SongbirdNestablePageBundle(),
4 new {your-initial}NestablePageBundle(),

and routing

 1 # app/config/routing.yml
 2 
 3 # songbird_nestable_page:
 4 #     resource: "@SongbirdNestablePageBundle/Controller/"
 5 #    type:     annotation
 6 #    prefix:   /
 7 
 8 {your-initial}_nestable_page:
 9     resource: "@{your-initial}/NestablePageBundle/Controller/"
10     type:     annotation
11     prefix:   /

My initial is bpeh, let us check that the routes are working.

1 -> ./scripts/console debug:router | grep bpeh
2 bpeh_page                GET      ANY    ANY  /bpeh_page/
3 bpeh_page_list           GET      ANY    ANY  /bpeh_page/list
4 bpeh_page_reorder        POST     ANY    ANY  /bpeh_page/reorder
5 ...

We can now install the assets.

1 -> gulp

Now go your new page list url and do a quick test. In my case,

1 http://songbird.app:8000/app_dev.php/bpeh_page/list

Looks like it is working. How can we be sure? Remember our functional tests?

1 -> ./scripts/phpunit vendor/bpeh/nestable-page-bundle/
2 ...

If it fails, why? Can you fix it?

Remember to commit your code before moving to the next chapter. Up your nestablepagebundle tags to 0.2.0 or something else since there were major changes.

Making the Bundle Extensible

When this bundle is initialised in AppKernel.php, running “scripts/console doctrine:schema:create will create the default tables. We should be able to extend this bundle and modify the entity name and methods easily. The war is not over. There are still lots to be done!!

Let us clean up the AppKernel and Route.

1 # app/AppKernel.php
2 ...
3 // new {your-inital}NestablePageBundle(),
4 ...

and in routing.yml

1 # app/config/routing.yml
2 
3 # {your-initial}_nestable_page:
4 # resource: "@{your-initial}NestablePageBundle/Controller/"
5 # type:     annotation
6 # prefix:   /

and refocus our attention to the NestablePageBundle:

1 -> cd vendor/{your-initial}/NestablePageBundle

First of all, we need to make Page and PageMeta entities extensible. We will move the entities to the Model directory, making the entities abstract.

I’ll be using my initial “bpeh” from now onwards to make life easier when referencing paths.

  1 # vendor/bpeh/nestable-page-bundle/Model/PageBase.php
  2 
  3 namespace Bpeh\NestablePageBundle\Model;
  4 
  5 use Doctrine\ORM\Mapping as ORM;
  6 
  7 /**
  8  * Page
  9  *
 10  */
 11 abstract class PageBase
 12 {
 13     /**
 14      * @var integer
 15      *
 16      * @ORM\Column(name="id", type="integer")
 17      * @ORM\Id
 18      * @ORM\GeneratedValue(strategy="AUTO")
 19      */
 20     protected $id;
 21 
 22     /**
 23      * @var string
 24      *
 25      * @ORM\Column(name="slug", type="string", length=255, unique=true)
 26      */
 27     protected $slug;
 28 
 29     /**
 30      * @var boolean
 31      *
 32      * @ORM\Column(name="isPublished", type="boolean", nullable=true)
 33      */
 34     protected $isPublished;
 35 
 36     /**
 37      * @var integer
 38      *
 39      * @ORM\Column(name="sequence", type="integer", nullable=true)
 40      */
 41     protected $sequence;
 42 
 43     /**
 44      * @var \DateTime
 45      *
 46      * @ORM\Column(name="modified", type="datetime")
 47      */
 48     protected $modified;
 49 
 50     /**
 51      * @var \DateTime
 52      *
 53      * @ORM\Column(name="created", type="datetime")
 54      */
 55     protected $created;
 56 
 57 
 58     /**
 59      * @ORM\ManyToOne(targetEntity="Bpeh\NestablePageBundle\Model\PageBase", inv\
 60 ersedBy="children")
 61      * @ORM\JoinColumn(name="parent_id", referencedColumnName="id", onDelete="CA\
 62 SCADE")}
 63      * @ORM\OrderBy({"sequence" = "ASC"})
 64      */
 65     protected $parent;
 66 
 67     /**
 68      * @ORM\OneToMany(targetEntity="Bpeh\NestablePageBundle\Model\PageBase", map\
 69 pedBy="parent")
 70      * @ORM\OrderBy({"sequence" = "ASC"})
 71      */
 72     protected $children;
 73    
 74     /**
 75      * @ORM\OneToMany(targetEntity="Bpeh\NestablePageBundle\Model\PageMetaBase",\
 76  mappedBy="page", cascade={"persist"}))
 77      */
 78     protected $pageMetas;
 79     
 80     /**
 81      * Get id
 82      *
 83      * @return integer 
 84      */
 85     public function getId()
 86     {
 87         return $this->id;
 88     }
 89 
 90     /**
 91      * Set slug
 92      *
 93      * @param string $slug
 94      * @return Page
 95      */
 96     public function setSlug($slug)
 97     {
 98         $this->slug = $slug;
 99 
100         return $this;
101     }
102 
103     /**
104      * Get slug
105      *
106      * @return string 
107      */
108     public function getSlug()
109     {
110         return $this->slug;
111     }
112 
113     /**
114      * Set isPublished
115      *
116      * @param boolean $isPublished
117      * @return Page
118      */
119     public function setIsPublished($isPublished)
120     {
121         $this->isPublished = $isPublished;
122 
123         return $this;
124     }
125 
126     /**
127      * Get isPublished
128      *
129      * @return boolean 
130      */
131     public function getIsPublished()
132     {
133         return $this->isPublished;
134     }
135 
136     /**
137      * Set sequence
138      *
139      * @param integer $sequence
140      * @return Page
141      */
142     public function setSequence($sequence)
143     {
144         $this->sequence = $sequence;
145 
146         return $this;
147     }
148 
149     /**
150      * Get sequence
151      *
152      * @return integer 
153      */
154     public function getSequence()
155     {
156         return $this->sequence;
157     }
158 
159     /**
160      * Set modified
161      *
162      * @param \DateTime $modified
163      * @return Page
164      */
165     public function setModified($modified)
166     {
167         $this->modified = $modified;
168 
169         return $this;
170     }
171 
172     /**
173      * Get modified
174      *
175      * @return \DateTime 
176      */
177     public function getModified()
178     {
179         return $this->modified;
180     }
181 
182     /**
183      * Set created
184      *
185      * @param \DateTime $created
186      * @return Page
187      */
188     public function setCreated($created)
189     {
190         $this->created = $created;
191 
192         return $this;
193     }
194 
195     /**
196      * Get created
197      *
198      * @return \DateTime 
199      */
200     public function getCreated()
201     {
202         return $this->created;
203     }
204 
205     /**
206      * Constructor
207      */
208     public function __construct()
209     {
210         $this->children = new \Doctrine\Common\Collections\ArrayCollection();
211         $this->pageMetas = new \Doctrine\Common\Collections\ArrayCollection();
212     }
213 
214     /**
215      * @ORM\PrePersist
216      */
217     public function prePersist()
218     {
219         // update the modified time
220         $this->setModified(new \DateTime());
221 
222         // for newly created entries
223         if ($this->getCreated() == null) {
224             $this->setCreated(new \DateTime('now'));
225         }
226         $this->created = new \DateTime();
227     }
228 
229     /**
230      * Set parent
231      *
232      * @param \Bpeh\NestablePageBundle\Model\PageBase $parent
233      * @return Page
234      */
235     public function setParent(\Bpeh\NestablePageBundle\Model\PageBase $parent = \
236 null)
237     {
238         $this->parent = $parent;
239 
240         return $this;
241     }
242 
243     /**
244      * Get parent
245      *
246      * @return \Bpeh\NestablePageBundle\Model\PageBase 
247      */
248     public function getParent()
249     {
250         return $this->parent;
251     }
252 
253     /**
254      * Add children
255      *
256      * @param \Bpeh\NestablePageBundle\Model\PageBase $children
257      * @return Page
258      */
259     public function addChild(\Bpeh\NestablePageBundle\Model\PageBase $children)
260     {
261         $this->children[] = $children;
262 
263         return $this;
264     }
265 
266     /**
267      * Remove children
268      *
269      * @param \Bpeh\NestablePageBundle\Model\Page $children
270      */
271     public function removeChild(\Bpeh\NestablePageBundle\Model\PageBase $childre\
272 n)
273     {
274         $this->children->removeElement($children);
275     }
276 
277     /**
278      * Get children
279      *
280      * @return \Doctrine\Common\Collections\Collection 
281      */
282     public function getChildren()
283     {
284         return $this->children;
285     }
286 
287     /**
288      * Add pageMetas
289      *
290      * @param \Bpeh\NestablePageBundle\Model\PageMetaBase $pageMetas
291      * @return Page
292      */
293     public function addPageMeta(\Bpeh\NestablePageBundle\Model\PageMetaBase $pag\
294 eMetas)
295     {
296         $this->pageMetas[] = $pageMetas;
297 
298         return $this;
299     }
300 
301     /**
302      * Remove pageMetas
303      *
304      * @param \Bpeh\NestablePageBundle\Model\PageMetaBase $pageMetas
305      */
306     public function removePageMeta(\Bpeh\NestablePageBundle\Model\PageMetaBase $\
307 pageMetas)
308     {
309         $this->pageMetas->removeElement($pageMetas);
310     }
311 
312     /**
313      * Get pageMetas
314      *
315      * @return \Doctrine\Common\Collections\Collection 
316      */
317     public function getPageMetas()
318     {
319         return $this->pageMetas;
320     }
321     
322     /**
323      * convert object to string
324      * @return string
325      */
326     public function __toString()
327     {
328         return $this->slug;
329     }
330 }

Note that we have changed all variables to “protected” to allow inheritance. The references to PageBase has also been changed.

To make our bundle flexible, we also need to allow user to specify their own child entities, form type and templates to use.

 1 # vendor/bpeh/nestable-page-bundle/DependencyInjection/Configuration.php
 2 
 3 namespace Bpeh\NestablePageBundle\DependencyInjection;
 4 
 5 use Symfony\Component\Config\Definition\Builder\TreeBuilder;
 6 use Symfony\Component\Config\Definition\ConfigurationInterface;
 7 
 8 /**
 9  * This is the class that validates and merges configuration from your app/confi\
10 g files
11  *
12  * To learn more see {@link http://symfony.com/doc/current/cookbook/bundles/exte\
13 nsion.html#cookbook-bundles-extension-config-class}
14  */
15 class Configuration implements ConfigurationInterface
16 {
17     /**
18      * {@inheritdoc}
19      */
20     public function getConfigTreeBuilder()
21     {
22         $treeBuilder = new TreeBuilder();
23         $rootNode = $treeBuilder->root('bpeh_nestable_page');
24         // Here you should define the parameters that are allowed to
25         // configure your bundle. See the documentation linked above for
26         // more information on that topic.
27         $rootNode
28             ->children()
29                 ->scalarNode('page_entity')->defaultValue('Bpeh\NestablePageBund\
30 le\PageTestBundle\Entity\Page')->end()
31                 ->scalarNode('pagemeta_entity')->defaultValue('Bpeh\NestablePage\
32 Bundle\PageTestBundle\Entity\PageMeta')->end()
33                 ->scalarNode('page_form_type')->defaultValue('Bpeh\NestablePageB\
34 undle\PageTestBundle\Form\PageType')->end()
35                 ->scalarNode('pagemeta_form_type')->defaultValue('Bpeh\NestableP\
36 ageBundle\PageTestBundle\Form\PageMetaType')->end()
37 	            ->scalarNode('page_view_list')->defaultValue('BpehNestablePageBundl\
38 e:Page:list.html.twig')->end()
39 		        ->scalarNode('page_view_edit')->defaultValue('BpehNestablePageBundle:P\
40 age:edit.html.twig')->end()
41 		        ->scalarNode('page_view_show')->defaultValue('BpehNestablePageBundle:P\
42 age:show.html.twig')->end()
43 		        ->scalarNode('page_view_new')->defaultValue('BpehNestablePageBundle:Pa\
44 ge:new.html.twig')->end()
45 		        ->scalarNode('pagemeta_view_new')->defaultValue('BpehNestablePageBundl\
46 e:PageMeta:new.html.twig')->end()
47 		        ->scalarNode('pagemeta_view_edit')->defaultValue('BpehNestablePageBund\
48 le:PageMeta:edit.html.twig')->end()
49 		        ->scalarNode('pagemeta_view_index')->defaultValue('BpehNestablePageBun\
50 dle:PageMeta:index.html.twig')->end()
51 		        ->scalarNode('pagemeta_view_show')->defaultValue('BpehNestablePageBund\
52 le:PageMeta:show.html.twig')->end()
53             ->end()
54         ;
55         return $treeBuilder;
56     }
57 }

and the extension

 1 # vendor/bpeh/nestable-page-bundle/DependencyInjection/BpehNestablePageExtension\
 2 .php
 3 
 4 namespace Bpeh\NestablePageBundle\DependencyInjection;
 5 
 6 use Symfony\Component\DependencyInjection\ContainerBuilder;
 7 use Symfony\Component\Config\FileLocator;
 8 use Symfony\Component\HttpKernel\DependencyInjection\Extension;
 9 use Symfony\Component\DependencyInjection\Loader\YamlFileLoader;
10 
11 /**
12  * This is the class that loads and manages your bundle configuration
13  *
14  * To learn more see {@link http://symfony.com/doc/current/cookbook/bundles/exte\
15 nsion.html}
16  */
17 class BpehNestablePageExtension extends Extension
18 {
19     /**
20      * {@inheritdoc}
21      */
22     public function load(array $configs, ContainerBuilder $container)
23     {
24         $configuration = new Configuration();
25         $config = $this->processConfiguration($configuration, $configs);
26 
27         $container->setParameter( 'bpeh_nestable_page.page_entity', $config[ 'pa\
28 ge_entity' ]);
29         $container->setParameter( 'bpeh_nestable_page.pagemeta_entity', $config[\
30  'pagemeta_entity' ]);
31         $container->setParameter( 'bpeh_nestable_page.page_form_type', $config[ \
32 'page_form_type' ]);
33         $container->setParameter( 'bpeh_nestable_page.pagemeta_form_type', $conf\
34 ig[ 'pagemeta_form_type' ]);
35 	    $container->setParameter( 'bpeh_nestable_page.page_view_list', $config[ 'pa\
36 ge_view_list' ]);
37 	    $container->setParameter( 'bpeh_nestable_page.page_view_new', $config[ 'pag\
38 e_view_new' ]);
39 	    $container->setParameter( 'bpeh_nestable_page.page_view_edit', $config[ 'pa\
40 ge_view_edit' ]);
41 	    $container->setParameter( 'bpeh_nestable_page.page_view_show', $config[ 'pa\
42 ge_view_show' ]);
43 	    $container->setParameter( 'bpeh_nestable_page.pagemeta_view_index', $config\
44 [ 'pagemeta_view_index' ]);
45 	    $container->setParameter( 'bpeh_nestable_page.pagemeta_view_edit', $config[\
46  'pagemeta_view_edit' ]);
47 	    $container->setParameter( 'bpeh_nestable_page.pagemeta_view_new', $config[ \
48 'pagemeta_view_new' ]);
49 	    $container->setParameter( 'bpeh_nestable_page.pagemeta_view_show', $config[\
50  'pagemeta_view_show' ]);
51 	    $loader = new YamlFileLoader($container, new FileLocator(__DIR__ . '/../Res\
52 ources/config'));
53 	    $loader->load('services.yml');
54     }
55 }

Now in config.yml, anyone can define the page and pagemeta entities themselves.

We also need to run the constructor to initialise the new config parameters when the controllers are loaded. To do that, we will need to do it via the controller event listener.

1 # vendor/bpeh/nestable-page-bundle/Resources/config/services.yml
2 
3 services:
4 
5   bpeh_nestable_page.init:
6     class: Bpeh\NestablePageBundle\EventListener\ControllerListener
7     tags:
8       - { name: kernel.event_listener, event: kernel.controller, method: onKerne\
9 lController}

and in the controller listener class

 1 # vendor/bpeh/nestable-page-bundle/EventListener/ControllerListener.php
 2 
 3 namespace Bpeh\NestablePageBundle\EventListener;
 4 
 5 use Symfony\Component\HttpKernel\Event\FilterControllerEvent;
 6 use Bpeh\NestablePageBundle\Controller\PageController;
 7 use Bpeh\NestablePageBundle\Controller\PageMetaController;
 8 
 9 class ControllerListener
10 {
11 
12     public function onKernelController(FilterControllerEvent $event)
13     {
14         $controller = $event->getController();
15 
16         /*
17          * controller must come in an array
18          */
19         if (!is_array($controller)) {
20             return;
21         }
22         
23         if ($controller[0] instanceof PageController || $controller[0] instanceo\
24 f PageMetaController) {
25             $controller[0]->init();
26         }
27     }
28 }

The Page Controller can now use the parameters as defined in config.yml to load the entities and form types.

  1 # vendor/bpeh/nestable-page-bundle/Controller/PageController.php
  2 
  3 namespace Bpeh\NestablePageBundle\Controller;
  4 
  5 use Bpeh\NestablePageBundle\Model\PageBase as Page;
  6 use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
  7 use Symfony\Component\HttpFoundation\Request;
  8 use Symfony\Bundle\FrameworkBundle\Controller\Controller;
  9 use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
 10 use Symfony\Component\HttpFoundation\JsonResponse;
 11 
 12 /**
 13  * Page controller.
 14  *
 15  * @Route("/bpeh_page")
 16  */
 17 class PageController extends Controller
 18 {
 19 
 20     private $entity;
 21 
 22 	private $entity_meta;
 23 
 24     private $page_form_type;
 25 
 26 	private $page_view_list;
 27 
 28 	private $page_view_new;
 29 
 30 	private $page_view_edit;
 31 
 32 	private $page_view_show;
 33 
 34     public function init()
 35     {
 36     	$this->entity = $this->container->getParameter('bpeh_nestable_page.page_ent\
 37 ity');
 38 	    $this->entity_meta = $this->container->getParameter('bpeh_nestable_page.pag\
 39 emeta_entity');
 40         $this->page_form_type = $this->container->getParameter('bpeh_nestable_pa\
 41 ge.page_form_type');
 42 	    $this->page_view_list = $this->container->getparameter('bpeh_nestable_page.\
 43 page_view_list');
 44 	    $this->page_view_new = $this->container->getparameter('bpeh_nestable_page.p\
 45 age_view_new');
 46 	    $this->page_view_edit = $this->container->getparameter('bpeh_nestable_page.\
 47 page_view_edit');
 48 	    $this->page_view_show = $this->container->getparameter('bpeh_nestable_page.\
 49 page_view_show');
 50     }
 51 
 52 	/**
 53 	 * Lists all Page entities.
 54 	 *
 55 	 * @Route("/", name="bpeh_page")
 56 	 * @Method("GET")
 57 	 *
 58 	 * @return \Symfony\Component\HttpFoundation\RedirectResponse
 59 	 */
 60     public function indexAction()
 61     {
 62 	    return $this->redirect($this->generateUrl('bpeh_page_list'));
 63     }
 64 
 65 	/**
 66 	 * Lists all nested page
 67 	 *
 68 	 * @Route("/list", name="bpeh_page_list")
 69 	 * @Method("GET")
 70 	 *
 71 	 * @return array
 72 	 */
 73     public function listAction()
 74     {
 75     	$em = $this->getDoctrine()->getManager();
 76         $rootMenuItems = $em->getRepository($this->entity)->findParent();
 77 
 78         return $this->render($this->page_view_list, array(
 79             'tree' => $rootMenuItems,
 80         ));
 81     }
 82 
 83 	/**
 84 	 * reorder pages
 85 	 *
 86 	 * @Route("/reorder", name="bpeh_page_reorder")
 87 	 * @Method("POST")
 88 	 *
 89 	 * @param Request $request
 90 	 *
 91 	 * @return JsonResponse
 92 	 */
 93     public function reorderAction(Request $request)
 94     {
 95 	    $em = $this->getDoctrine()->getManager();
 96 	    // id of affected element
 97 	    $id = $request->get('id');
 98 
 99 	    // if invalid token, fail silently
100 	    if (!$this->isCsrfTokenValid('bpeh_page_reorder', $request->get('csrf'))) {
101 		    // fail silently
102 		    return;
103 	    }
104 
105 	    // parent Id
106 	    $parentId = ($request->get('parentId') == '') ? null : $request->get('paren\
107 tId');
108 	    // new sequence of this element. 0 means first element.
109 	    $position = $request->get('position');
110 
111 	    $result = $em->getRepository($this->entity)->reorderElement($id, $parentId,\
112  $position);
113 
114 	    return new JsonResponse(
115 		    array('message' => $this->get('translator')->trans($result[0], array(), 'B\
116 pehNestablePageBundle')
117 		    , 'success' => $result[1])
118 	    );
119     }
120 
121 	/**
122 	 * Creates a new Page entity.
123 	 *
124 	 * @Route("/new", name="bpeh_page_new")
125 	 * @Method({"GET", "POST"})
126 	 *
127 	 * @param Request $request
128 	 *
129 	 * @return array|\Symfony\Component\HttpFoundation\RedirectResponse
130 	 */
131     public function newAction(Request $request)
132     {
133 	    $page = new $this->entity();
134 	    $form = $this->createForm($this->page_form_type, $page);
135 	    $form->handleRequest($request);
136 
137 	    if ($form->isSubmitted() && $form->isValid()) {
138 		    $em = $this->getDoctrine()->getManager();
139 		    $em->persist($page);
140 		    $em->flush();
141 
142 		    return $this->redirectToRoute('bpeh_page_show', array('id' => $page->getId\
143 ()));
144 	    }
145 
146 	    return $this->render($this->page_view_new, array(
147 		    'page' => $page,
148 		    'form' => $form->createView(),
149 	    ));
150     }
151 
152 	/**
153 	 * Finds and displays a Page entity.
154 	 *
155 	 * @Route("/{id}", name="bpeh_page_show")
156 	 * @Method("GET")
157 	 *
158 	 * @param Request $request
159 	 *
160 	 * @return array
161 	 */
162 	public function showAction(Request $request)
163 	{
164 		$em = $this->getDoctrine()->getManager();
165 
166 		$page = $em->getRepository($this->entity)->find($request->get('id'));
167 
168 		$pageMeta = $em->getRepository($this->entity_meta)->findPageMetaByLocale($page\
169 ,$request->getLocale());
170 
171 		$deleteForm = $this->createDeleteForm($page);
172 
173 		return $this->render($this->page_view_show, array(
174 			'page' => $page,
175 			'pageMeta' => $pageMeta,
176 			'delete_form' => $deleteForm->createView(),
177 		));
178 
179 	}
180 
181 	/**
182 	 * Displays a form to edit an existing Page entity.
183 	 *
184 	 * @Route("/{id}/edit", name="bpeh_page_edit")
185 	 * @Method({"GET", "POST"})
186 	 *
187 	 * @param Request $request
188 	 *
189 	 * @return array|\Symfony\Component\HttpFoundation\RedirectResponse
190 	 */
191 	public function editAction(Request $request)
192 	{
193 		$em = $this->getDoctrine()->getManager();
194 		$page = $em->getRepository($this->entity)->find($request->get('id'));
195 		$deleteForm = $this->createDeleteForm($page);
196 		$editForm = $this->createForm($this->page_form_type, $page);
197 		$editForm->handleRequest($request);
198 
199 		if ($editForm->isSubmitted() && $editForm->isValid()) {
200 			$em = $this->getDoctrine()->getManager();
201 			$em->persist($page);
202 			$em->flush();
203 
204 			return $this->redirectToRoute('bpeh_page_edit', array('id' => $page->getId())\
205 );
206 		}
207 
208 		return $this->render($this->page_view_edit, array(
209 			'page' => $page,
210 			'edit_form' => $editForm->createView(),
211 			'delete_form' => $deleteForm->createView(),
212 		));
213 
214 	}
215 
216 	/**
217 	 * Deletes a Page entity.
218 	 *
219 	 * @Route("/{id}", name="bpeh_page_delete")
220 	 * @Method("DELETE")
221 	 *
222 	 * @param Request $request
223 	 *
224 	 * @return \Symfony\Component\HttpFoundation\RedirectResponse
225 	 */
226 	public function deleteAction(Request $request)
227 	{
228 		$em = $this->getDoctrine()->getManager();
229 		$page = $em->getRepository($this->entity)->find($request->get('id'));
230 		$form = $this->createDeleteForm($page);
231 		$form->handleRequest($request);
232 
233 		if ($form->isSubmitted() && $form->isValid()) {
234 			$em = $this->getDoctrine()->getManager();
235 			$em->remove($page);
236 			$em->flush();
237 		}
238 
239 		return $this->redirectToRoute('bpeh_page_list');
240 	}
241 
242 	/**
243 	 * Creates a form to delete a Page entity.
244 	 *
245 	 * @return \Symfony\Component\Form\Form The form
246 	 */
247 	private function createDeleteForm(Page $page)
248 	{
249 		return $this->createFormBuilder()
250 		            ->setAction($this->generateUrl('bpeh_page_delete', array('id' => $\
251 page->getId())))
252 		            ->setMethod('DELETE')
253 		            ->getForm()
254 			;
255 	}
256 
257 }

Likewise for PageMeta Controller

  1 # vendor/bpeh/nestable-page-bundle/Controller/PageMetaController.php
  2 
  3 namespace Bpeh\NestablePageBundle\Controller;
  4 
  5 use Symfony\Component\HttpFoundation\Request;
  6 use Symfony\Bundle\FrameworkBundle\Controller\Controller;
  7 use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
  8 use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
  9 use Bpeh\NestablePageBundle\Model\PageMetaBase as PageMeta;
 10 
 11 /**
 12  * PageMeta controller.
 13  *
 14  * @Route("/bpeh_pagemeta")
 15  */
 16 class PageMetaController extends Controller
 17 {
 18 
 19 	private $entity;
 20 
 21 	private $entity_meta;
 22 
 23 	private $page_meta_form_type;
 24 
 25 	private $pagemeta_view_index;
 26 
 27 	private $pagemeta_view_new;
 28 
 29 	private $pagemeta_view_edit;
 30 
 31 	private $pagemeta_view_show;
 32 
 33 	public function init()
 34 	{
 35 		$this->entity = $this->container->getParameter('bpeh_nestable_page.page_entity\
 36 ');
 37 		$this->entity_meta = $this->container->getParameter('bpeh_nestable_page.pageme\
 38 ta_entity');
 39 		$this->page_meta_form_type = $this->container->getParameter('bpeh_nestable_pag\
 40 e.pagemeta_form_type');
 41 		$this->pagemeta_view_index = $this->container->getparameter('bpeh_nestable_pag\
 42 e.pagemeta_view_index');
 43 		$this->pagemeta_view_new = $this->container->getparameter('bpeh_nestable_page.\
 44 pagemeta_view_new');
 45 		$this->pagemeta_view_edit = $this->container->getparameter('bpeh_nestable_page\
 46 .pagemeta_view_edit');
 47 		$this->pagemeta_view_show = $this->container->getparameter('bpeh_nestable_page\
 48 .pagemeta_view_show');
 49 	}
 50 
 51 	/**
 52 	 * Lists all PageMeta entities.
 53 	 *
 54 	 * @Route("/", name="bpeh_pagemeta_index")
 55 	 * @Method("GET")
 56 	 */
 57 	public function indexAction()
 58 	{
 59 		$em = $this->getDoctrine()->getManager();
 60 
 61 		$pageMetas = $em->getRepository($this->entity_meta)->findAll();
 62 
 63 		return $this->render($this->pagemeta_view_index, array(
 64 			'pageMetas' => $pageMetas,
 65 		));
 66 
 67 	}
 68 
 69 	/**
 70 	 * Creates a new PageMeta entity.
 71 	 *
 72 	 * @Route("/new", name="bpeh_pagemeta_new")
 73 	 * @Method({"GET", "POST"})
 74 	 */
 75 	public function newAction(Request $request)
 76 	{
 77 		$pageMeta = new $this->entity_meta();
 78 		$form = $this->createForm($this->page_meta_form_type, $pageMeta);
 79 		$form->handleRequest($request);
 80 
 81 		if ($form->isSubmitted() && $form->isValid()) {
 82 			$em = $this->getDoctrine()->getManager();
 83 
 84 			if ( $em->getRepository( $this->entity_meta )->findPageMetaByLocale( $pageMet\
 85 a->getPage(), $pageMeta->getLocale() ) ) {
 86 				$this->get('session')->getFlashBag()->add( 'error', $this->get('translator')\
 87 ->trans('one_locale_per_pagemeta_only', array(), 'BpehNestablePageBundle') );
 88 			} else {
 89 				$em->persist( $pageMeta );
 90 				$em->flush();
 91 				return $this->redirectToRoute( 'bpeh_pagemeta_show', array( 'id' => $pageMet\
 92 a->getId() ) );
 93 			}
 94 		}
 95 
 96 		return $this->render($this->pagemeta_view_new, array(
 97 			'pageMeta' => $pageMeta,
 98 			'form' => $form->createView(),
 99 		));
100 
101 	}
102 
103 	/**
104 	 * Finds and displays a PageMeta entity.
105 	 *
106 	 * @Route("/{id}", name="bpeh_pagemeta_show")
107 	 * @Method("GET")
108 	 */
109 	public function showAction(Request $request)
110 	{
111 		$em = $this->getDoctrine()->getManager();
112 
113 		$pageMeta = $em->getRepository($this->entity_meta)->find($request->get('id'));
114 
115 		$deleteForm = $this->createDeleteForm($pageMeta);
116 
117 		return $this->render($this->pagemeta_view_show, array(
118 			'pageMeta' => $pageMeta,
119 			'delete_form' => $deleteForm->createView(),
120 		));
121 	}
122 
123 	/**
124 	 * Displays a form to edit an existing PageMeta entity.
125 	 *
126 	 * @Route("/{id}/edit", name="bpeh_pagemeta_edit")
127 	 * @Method({"GET", "POST"})
128 	 */
129 	public function editAction(Request $request)
130 	{
131 		$em = $this->getDoctrine()->getManager();
132 		$pageMeta = $em->getRepository($this->entity_meta)->find($request->get('id'));
133 		$origId = $pageMeta->getPage()->getId();
134 		$origLocale = $pageMeta->getLocale();
135 
136 		$deleteForm = $this->createDeleteForm($pageMeta);
137 		$editForm = $this->createForm($this->page_meta_form_type, $pageMeta);
138 		$editForm->handleRequest($request);
139 
140 		if ($editForm->isSubmitted() && $editForm->isValid()) {
141 
142 			$error = false;
143 
144 			// if page and local is the same, dont need to check locale count
145 			if ($origLocale == $pageMeta->getLocale() && $origId == $pageMeta->getPage()-\
146 >getId()) {
147 				// all good
148 			}
149 			elseif ( $em->getRepository( $this->entity_meta )->findPageMetaByLocale( $pag\
150 eMeta->getPage(), $pageMeta->getLocale(), true ) ) {
151 				$this->get('session')->getFlashBag()->add( 'error', $this->get('translator')\
152 ->trans('one_locale_per_pagemeta_only', array(), 'BpehNestablePageBundle') );
153 				$error = true;
154 			}
155 
156 			// if everything is successful
157 			if (!$error) {
158 				$em->persist( $pageMeta );
159 				$em->flush();
160 				return $this->redirectToRoute( 'bpeh_pagemeta_edit', array( 'id' => $pageMet\
161 a->getId() ) );
162 			}
163 		}
164 
165 		return $this->render($this->pagemeta_view_edit, array(
166 			'pageMeta' => $pageMeta,
167 			'edit_form' => $editForm->createView(),
168 			'delete_form' => $deleteForm->createView(),
169 		));
170 	}
171 
172 	/**
173 	 * Deletes a PageMeta entity.
174 	 *
175 	 * @Route("/{id}", name="bpeh_pagemeta_delete")
176 	 * @Method("DELETE")
177 	 */
178 	public function deleteAction(Request $request)
179 	{
180 		$em = $this->getDoctrine()->getManager();
181 		$pageMeta = $em->getRepository($this->entity_meta)->find($request->get('id'));
182 		$form = $this->createDeleteForm($pageMeta);
183 		$form->handleRequest($request);
184 
185 		if ($form->isSubmitted() && $form->isValid()) {
186 			$em = $this->getDoctrine()->getManager();
187 			$em->remove($pageMeta);
188 			$em->flush();
189 		}
190 
191 		return $this->redirectToRoute('bpeh_pagemeta_index');
192 	}
193 
194 	/**
195 	 * Creates a form to delete a PageMeta entity.
196 	 *
197 	 * @param PageMeta $pageMetum The PageMeta entity
198 	 *
199 	 * @return \Symfony\Component\Form\Form The form
200 	 */
201 	private function createDeleteForm(PageMeta $pageMeta)
202 	{
203 		return $this->createFormBuilder()
204 		            ->setAction($this->generateUrl('bpeh_pagemeta_delete', array('id' \
205 => $pageMeta->getId())))
206 		            ->setMethod('DELETE')
207 		            ->getForm()
208 			;
209 	}
210 }

We also need to refactor PageMetaRepository because findPageMetaByLocale can now return either an object or scalar value.

 1 # vendor/bpeh/nestable-page-bundle/Repository/PageMetaRepository.php
 2 
 3 namespace Bpeh\NestablePageBundle\Repository;
 4 
 5 use Bpeh\NestablePageBundle\Model\PageBase;
 6 
 7 /**
 8  * PageMetaRepository
 9  *
10  * This class was generated by the Doctrine ORM. Add your own custom
11  * repository methods below.
12  */
13 class PageMetaRepository extends \Doctrine\ORM\EntityRepository {
14 
15     /**
16      * @param PageBase $page
17      * @param $locale
18      * @param bool $count
19      * @return mixed
20      */
21 	public function findPageMetaByLocale( PageBase $page, $locale, $count = false )\
22  {
23 
24 		$qb = $this->createQueryBuilder( 'pm' );
25 
26 		if ( $count ) {
27 			$qb->select( 'count(pm.id)' );
28 		}
29 
30 		$query = $qb->where( 'pm.locale = :locale' )
31 	      ->andWhere( 'pm.page = :page' )
32 	      ->setParameter( 'locale', $locale )
33 	      ->setParameter( 'page', $page )
34 	      ->getQuery();
35 
36 		if ( $count ) {
37 			return $query->getSingleScalarResult();
38 		}
39 
40 		return $query->getOneOrNullResult();
41 
42 	}
43 }

There are other stuff to be done

  • Create the translations.
  • Move all the related views from app/resources/views to vendor/bpeh/nestable-page-bundle/views
  • Update functional tests.

Once you are happy with it, give it a new tag and commit your changes again.

The bundle is now ready to be extended.

Extending BpehNestablePageBundle

To make things easy, I’ve created a demo bundle and you can install the demo bundle and test out it for yourself.

Let us extend BpehNestablePageBundle by copying the PageTestBundle.

 1 -> cd src/AppBundle
 2 -> cp ../../vendor/bpeh/nestable-page-bundle/PageTestBundle/PageTestBundle.php .
 3 # let us name it page bundle
 4 -> mv PageTestBundle.php Page.php
 5 -> cp ../../vendor/bpeh/nestable-page-bundle/PageTestBundle/Controller/*.php Con\
 6 troller/
 7 -> cp ../../vendor/bpeh/nestable-page-bundle/PageTestBundle/Entity/*.php Entity/
 8 -> cp ../../vendor/bpeh/nestable-page-bundle/PageTestBundle/Repository/*.php Rep\
 9 ository/
10 -> cp -a ../../vendor/bpeh/nestable-page-bundle/PageTestBundle/Form .
11 -> cp ../../vendor/bpeh/nestable-page-bundle/PageTestBundle/DataFixtures/ORM/Loa\
12 dPageData.php DataFixtures/ORM/

Let us call this bundle PageBundle to keep it simple.

 1 # src/AppBundle/Page.php
 2 
 3 namespace AppBundle;
 4 
 5 use Symfony\Component\HttpKernel\Bundle\Bundle;
 6 
 7 class Page extends Bundle
 8 {
 9 	// use a child bundle
10 	public function getParent()
11 	{
12 		return 'BpehNestablePageBundle';
13 	}
14 }

Let us configure the Entities

 1 # src/AppBundle/Entity/Page.php
 2 
 3 namespace AppBundle\Entity;
 4 
 5 use Bpeh\NestablePageBundle\Model\PageBase;
 6 use Doctrine\ORM\Mapping as ORM;
 7 
 8 /**
 9  * Page
10  *
11  * @ORM\Table(name="page")
12  * @ORM\Entity(repositoryClass="AppBundle\Repository\PageRepository")
13  * @ORM\HasLifecycleCallbacks()
14  */
15 class Page extends PageBase
16 {
17     /**
18      * @var integer
19      *
20      * @ORM\Column(name="id", type="integer")
21      * @ORM\Id
22      * @ORM\GeneratedValue(strategy="AUTO")
23      */
24     protected $id;
25 
26     /**
27      * Get id
28      *
29      * @return integer
30      */
31     public function getId()
32     {
33         return $this->id;
34     }
35 
36 }

and PageMeta.php

 1 # src/AppBundle/Entity/PageMeta.php
 2 
 3 namespace AppBundle\Entity;
 4 
 5 use Bpeh\NestablePageBundle\Model\PageMetaBase;
 6 use Doctrine\ORM\Mapping as ORM;
 7 
 8 /**
 9  * PageMeta
10  *
11  * @ORM\Table(name="pagemeta")
12  * @ORM\Entity(repositoryClass="AppBundle\Repository\PageMetaRepository")
13  * @ORM\HasLifecycleCallbacks() 
14  */
15 class PageMeta extends PageMetaBase
16 {
17     /**
18      * @var integer
19      *
20      * @ORM\Column(name="id", type="integer")
21      * @ORM\Id
22      * @ORM\GeneratedValue(strategy="AUTO")
23      */
24     protected $id;
25 
26     /**
27      * Get id
28      *
29      * @return integer
30      */
31     public function getId()
32     {
33         return $this->id;
34     }
35 
36 }

Let us update the PageRepository.php

 1 # src/AppBundle/Repository/PageRepository.php
 2 
 3 namespace AppBundle\Repository;
 4 
 5 use Bpeh\NestablePageBundle\Repository\PageRepository as BasePageRepository;
 6 
 7 /**
 8  * PageRepository
 9  *
10  */
11 class PageRepository extends BasePageRepository
12 {
13 
14 }

and PageMetaRepository.php

 1 # src/AppBundle/Repository/PageRepository.php
 2 
 3 namespace AppBundle\Repository;
 4 
 5 use Bpeh\NestablePageBundle\Repository\PageMetaRepository as BasePageMetaReposit\
 6 ory;
 7 
 8 /**
 9  * PageRepository
10  *
11  */
12 class PageMetaRepository extends BasePageMetaRepository
13 {
14 
15 }

Let us update the PageController to have a route which is easier to use

 1 # src/AppBundle/Controller/PageController.php
 2 
 3 namespace AppBundle\Controller;
 4 
 5 use Bpeh\NestablePageBundle\Controller\PageController as BaseController;
 6 use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
 7 
 8 /**
 9  * Page controller.
10  * @Route("/page")
11  */
12 class PageController extends BaseController
13 {
14 
15 }

Now PageMetaController.php

 1 # src/AppBundle/Controller/PageMetaController.php
 2 namespace AppBundle\Controller;
 3 
 4 use Bpeh\NestablePageBundle\Controller\PageMetaController as BaseController;
 5 use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
 6 
 7 /**
 8  * PageMeta controller.
 9  * @Route("/pagemeta")
10  */
11 class PageMetaController extends BaseController
12 {
13 
14 }

Its time to update the basic forms

 1 # src/AppBundle/Form/PageType.php
 2 
 3 namespace AppBundle\Form;
 4 
 5 use Symfony\Component\Form\FormBuilderInterface;
 6 use Symfony\Component\OptionsResolver\OptionsResolver;
 7 use Bpeh\NestablePageBundle\Form\PageType as BasePageType;
 8 
 9 class PageType extends BasePageType
10 {
11 
12 	/**
13 	 * @param FormBuilderInterface $builder
14 	 * @param array $options
15 	 */
16 	public function buildForm(FormBuilderInterface $builder, array $options)
17 	{
18 		parent::buildForm($builder,$options);
19 	}
20 
21 	/**
22 	 * @param OptionsResolver $resolver
23 	 */
24 	public function configureOptions(OptionsResolver $resolver)
25 	{
26 		$resolver->setDefaults(array(
27 			'data_class' => 'AppBundle\Entity\Page',
28 		));
29 	}
30 }

and PageMetaType.php

 1 # src/AppBundle/Form/PageMetaType.php
 2 
 3 namespace AppBundle\Form;
 4 
 5 use Symfony\Component\OptionsResolver\OptionsResolver;
 6 use Symfony\Component\Form\FormBuilderInterface;
 7 use Bpeh\NestablePageBundle\Form\PageMetaType as BasePageMetaType;
 8 
 9 class PageMetaType extends BasePageMetaType
10 {
11 
12 	/**
13 	 * @param FormBuilderInterface $builder
14 	 * @param array $options
15 	 */
16 	public function buildForm(FormBuilderInterface $builder, array $options)
17 	{
18 		parent::buildForm($builder,$options);
19 	}
20 
21 	/**
22 	 * @param OptionsResolver $resolver
23 	 */
24 	public function configureOptions(OptionsResolver $resolver)
25 	{
26 		$resolver->setDefaults(array(
27 			'data_class' => 'AppBundle\Entity\PageMeta'
28 		));
29 	}
30 }

Let us confirm the new routes are working…

 1 -> ./scripts/console debug:router | grep page
 2      bpeh_page                        GET        ANY      ANY    /page/
 3      bpeh_page_list                   GET        ANY      ANY    /page/list
 4      bpeh_page_reorder                POST       ANY      ANY    /page/reorder
 5      bpeh_page_new                    GET|POST   ANY      ANY    /page/new
 6      bpeh_page_show                   GET        ANY      ANY    /page/{id}
 7      bpeh_page_edit                   GET|POST   ANY      ANY    /page/{id}/edit
 8      bpeh_page_delete                 DELETE     ANY      ANY    /page/{id}
 9      bpeh_pagemeta_index              GET        ANY      ANY    /pagemeta/
10      bpeh_pagemeta_new                GET|POST   ANY      ANY    /pagemeta/new
11      bpeh_pagemeta_show               GET        ANY      ANY    /pagemeta/{id}
12      bpeh_pagemeta_edit               GET|POST   ANY      ANY    /pagemeta/{id}/\
13 edit
14      bpeh_pagemeta_delete             DELETE     ANY      ANY    /pagemeta/{id}

Looks good. Its time to update config.yml

 1 # app/config/config.yml
 2 ...
 3     orm:
 4         auto_generate_proxy_classes: "%kernel.debug%"
 5         naming_strategy: doctrine.orm.naming_strategy.underscore
 6         auto_mapping: true
 7         resolve_target_entities:
 8             Bpeh\NestablePageBundle\Model\PageBase: AppBundle\Entity\Page
 9             Bpeh\NestablePageBundle\Model\PageMetaBase: AppBundle\Entity\PageMeta
10 ...
11 # Nestable Page Configuration
12 bpeh_nestable_page:
13     page_entity: AppBundle\Entity\Page
14     pagemeta_entity: AppBundle\Entity\PageMeta
15     page_form_type: AppBundle\Form\PageType
16     pagemeta_form_type: AppBundle\Form\PageMetaType
17     # Customise the template if you want.
18     # page_view_list: YourBundle:list.html.twig
19     # page_view_new: YourBundle:new.html.twig
20     # page_view_edit: YourBundle:edit.html.twig
21     # page_view_show: YourBundle:show.html.twig
22     # pagemeta_view_index: YourBundle:index.html.twig
23     # pagemeta_view_new: YourBundle:new.html.twig
24     # pagemeta_view_edit: YourBundle:edit.html.twig
25     # pagemeta_view_show: YourBundle:show.html.twig

Remember to clean up the routes.

1 # app/config/routing.yml
2 
3 # remove these
4 # nestable_page:
5 #    resource: "@PageTestBundle/Controller/"
6 #    type:     annotation
7 #    prefix:   /

Init the new bundle in AppKernel.php

1 # app/AppKernel.php
2 
3 ...
4     new Bpeh\NestablePageBundle\BpehNestablePageBundle(),
5     new AppBundle\Page()
6 ...

Remember to update the data fixtures.

 1 # src/AppBundle/DataFixtures/ORM/LoadPageData.php
 2 
 3 use Doctrine\Common\DataFixtures\AbstractFixture;
 4 use Doctrine\Common\Persistence\ObjectManager;
 5 use Doctrine\Common\DataFixtures\OrderedFixtureInterface;
 6 use Symfony\Component\DependencyInjection\ContainerAwareInterface;
 7 use Symfony\Component\DependencyInjection\ContainerInterface;
 8 use AppBundle\Entity\Page;
 9 use AppBundle\Entity\PageMeta;
10 ...

There were schema changes. We have to update the sql so that we can deploy it easily if we need to. stash our work and load chapter_16 db.

1 -> git stash
2 -> git checkout chapter_16
3 -> ./scripts/resetapp
4 -> ./scripts/console doctrine:migrations:diff
5 -> git checkout mychapter_18
6 -> git stash pop
7 # we can commit everything at this stage
8 -> git add .
9 -> git commit

Reset the db again.

1 -> ./scripts/resetapp

Now go to http://songbird.app:8000/app_dev.php/page and make sure the new url should be working.

run all the tests and make sure you didn’t break anything.

1 -> ./scripts/runtest

I hope you are getting used to this… Its a pretty routine process once you get used to it.

Summary

In this chapter, we have created a new repo for the NestablePageBundle. We have updated composer to pull the bundle from the repo and auto-loaded it according to the PSR-4 standard. We learned the hard way of creating a non-extensible bundle with the wrong namespace and then mass renaming it again. Making the entities extensible was a massive job and required a lot of refactoring in our code. If you know you are creating a reusable bundle, its better to get the namespace correct and create it right from the start.

We have done so much to make NestablePageBundle as decoupled as possible. Still, there are lots of room for improvement. Was it worth the effort? Definitely! People can now install our bundle in their Symfony applications easily.

Exercises

  • Delete the whole vendor directory and try doing a composer update. Did anything break?
  • Update the functional test.

References

Chapter 19: The Page Manager Part 2

In this chapter, we are going to integrate NestablePageBundle with EasyAdminBundle. We are also going to improve the cms by integrating a wysiwyg editor (ckeditor) and create a custom locale dropdown.

Define User Stories

19. Page Management

Story Id As a I So that I
19.1 an admin want to manage pages update them anytime.
19.2 test1 user don’t want to manage pages don’t breach security

Story ID 19.1: As an admin, I want to manage pages, so that I can update them anytime.

Scenario Id Given When Then
19.11 List Pages I go to page list url I can see 2 elements under the about slug
19.12 Show Contact Us Page I go to contact_us page I should see the word “contact_us” and the word “Created”
19.13 Reorder home I drag and drop the home menu to under the about menu I should see “reordered successfully message” in the response and see 3 items under the about menu
19.14 edit home page meta I go to edit homepage url and update the menu title of “Home” to “Home1” and click update I should see the menu updated to home1
19.15 Create and delete test page go to page list and click “Add new page” and fill in details and click “Create” button, go to newly created test page and create 2 new test meta. Delete one testmeta and then delete the whole test page I should see the first pagemeta being created and deleted. Then see the second testmeta being deleted when the page is being deleted.
19.16 Delete Contact Us Page go to contact us page and click “delete” I should see that the contact us page and its associate meta being deleted.
19.17 Create new page with existing locale go to page list and click “Add new pagemeta” and fill in details, select locale as en, page as home and click “Create” button I should see an exception.

Story ID 19.2: As test1 user, I don’t want to manage pages, so that I don’t breach security.

Scenario Id Given** When Then
19.21 List pages I go to the page management url I should get a access denied message
19.22 show about us page I go to show about us url I should get a access denied message
19.23 edit about us page I go to edit about us url I should get a access denied message
19.24 List pagemeta I go to list pagemeta url I should get a access denied message

Adding new image field to PageMeta Entity

Let us add a new field called featuredImage to the PageMeta entity. We will configure Vich uploader to do the job.

 1 # src/AppBundle/Entity/PageMeta.php
 2 
 3 namespace AppBundle\Entity;
 4 
 5 use Bpeh\NestablePageBundle\Model\PageMetaBase;
 6 use Doctrine\ORM\Mapping as ORM;
 7 use Vich\UploaderBundle\Mapping\Annotation as Vich;
 8 use Symfony\Component\HttpFoundation\File\File;
 9 
10 /**
11  * PageMeta
12  *
13  * @ORM\Table(name="pagemeta")
14  * @ORM\Entity(repositoryClass="AppBundle\Repository\PageMetaRepository")
15  * @ORM\HasLifecycleCallbacks()
16  * @Vich\Uploadable
17  *
18  */
19 class PageMeta extends PageMetaBase
20 {
21     /**
22      * @var integer
23      *
24      * @ORM\Column(name="id", type="integer")
25      * @ORM\Id
26      * @ORM\GeneratedValue(strategy="AUTO")
27      */
28     protected $id;
29 
30     /**
31      * @ORM\Column(type="string", length=255, nullable=true)
32      * @var string
33      */
34     private $featuredImage;
35 
36     /**
37      * @Vich\UploadableField(mapping="featured_image", fileNameProperty="feature\
38 dImage")
39      * @var File
40      */
41     private $featuredImageFile;
42 
43     /**
44      * Get id
45      *
46      * @return integer
47      */
48     public function getId()
49     {
50         return $this->id;
51     }
52 
53     /**
54      * @param File|null $image
55      */
56     public function setFeaturedImageFile(File $image = null)
57     {
58         $this->featuredImageFile = $image;
59 
60         if ($image) {
61             $this->setModified(new \DateTime());
62         }
63     }
64 
65     /**
66      * @return File
67      */
68     public function getFeaturedImageFile()
69     {
70         return $this->featuredImageFile;
71     }
72 
73     /**
74      * @param $image
75      */
76     public function setFeaturedImage($image)
77     {
78         $this->featuredImage = $image;
79     }
80 
81     /**
82      * @return string
83      */
84     public function getFeaturedImage()
85     {
86         return $this->featuredImage;
87     }
88 
89     /**
90      * @return string
91      */
92     public function __toString()
93     {
94         return $this->getLocale().': '.$this->getMenuTitle();
95     }
96 }

Let us update config.yml

 1 # app/config/config.yml
 2 parameters:
 3     locale: en
 4     supported_lang: [ 'en', 'fr']
 5     admin_path: admin
 6     app.profile_image.path: /uploads/profiles
 7     app.featured_image.path: /uploads/featured_images
 8 ...
 9 vich_uploader:
10     db_driver: orm
11     mappings:
12         profile_images:
13             uri_prefix: '%app.profile_image.path%'
14             upload_destination: '%kernel.root_dir%/../web/uploads/profiles'
15             namer: vich_uploader.namer_uniqid
16         featured_image:
17             uri_prefix: '%app.featured_image.path%'
18             upload_destination: '%kernel.root_dir%/../web/uploads/featured_image\
19 s'
20             namer: vich_uploader.namer_uniqid

Installing CKEditor

We will now install CKEditor

1 -> ./scripts/composer require egeloen/ckeditor-bundle

then enable the bundle

 1 # app/AppKernel.php
 2 class AppKernel extends Kernel
 3 {
 4     public function registerBundles()
 5     {
 6         return array(
 7             // ...
 8             new Ivory\CKEditorBundle\IvoryCKEditorBundle(),
 9         );
10     }
11 }

Integration with EasyAdminBundle

There is still some effort to get BpehNestablePageBundle integrate properly with EasyAdminBundle. The reason is because the big difference in controller logic between the 2 bundles.

Let us assume that we not going to use the PageController.php and PageMetaController.php except the reorder route

The new routing.yml as follows:

 1 # app/config/routing.yml
 2 
 3 admin:
 4   resource: "@AppBundle/Controller/AdminController.php"
 5   type:     annotation
 6 
 7 locale:
 8   resource: "@AppBundle/Controller/LocaleController.php"
 9   type:     annotation
10 
11 bpeh_page_reorder:
12   path: /admin/reorder
13   defaults:
14     _controller: BpehNestablePageBundle:Page:reorder
15 
16 # FOS user bundle default routing
17 fos_user_security:
18   resource: "@FOSUserBundle/Resources/config/routing/security.xml"
19 
20 fos_user_resetting:
21   resource: "@FOSUserBundle/Resources/config/routing/resetting.xml"
22   prefix: /resetting
23 
24 easy_admin_bundle:
25   resource: "@AppBundle/Controller/AdminController.php"
26   type:     annotation
27   prefix:   /%admin_path%

We now need to add actions to the AdminController. The new AdminController should look like this:

  1 # src/AppBundle/Controller/AdminController.php
  2 
  3 namespace AppBundle\Controller;
  4 
  5 use JavierEguiluz\Bundle\EasyAdminBundle\Controller\AdminController as BaseAdmin\
  6 Controller;
  7 use JavierEguiluz\Bundle\EasyAdminBundle\Event\EasyAdminEvents;
  8 use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
  9 use Symfony\Component\HttpFoundation\Request;
 10 use Symfony\Component\HttpFoundation\Response;
 11 use AppBundle\Entity\PageMeta;
 12 
 13 class AdminController extends BaseAdminController
 14 {
 15     /**
 16      * @Route("/dashboard", name="dashboard")
 17      *
 18      * @param Request $request
 19      * @return \Symfony\Component\HttpFoundation\Response
 20      *
 21      */
 22     public function dashboardAction(Request $request)
 23     {
 24         return $this->render('@EasyAdmin/default/dashboard.html.twig');
 25     }
 26 
 27     /**
 28      * @return \Symfony\Component\HttpFoundation\Response
 29      */
 30     public function showUserAction()
 31     {
 32         $this->dispatch(EasyAdminEvents::PRE_SHOW);
 33         $id = $this->request->query->get('id');
 34         $easyadmin = $this->request->attributes->get('easyadmin');
 35         $entity = $easyadmin['item'];
 36 
 37         $fields = $this->entity['show']['fields'];
 38 
 39         if (!$this->isGranted('ROLE_SUPER_ADMIN')) {
 40             unset($fields['created']);
 41         }
 42 
 43         $deleteForm = $this->createDeleteForm($this->entity['name'], $id);
 44 
 45         return $this->render($this->entity['templates']['show'], array(
 46             'entity' => $entity,
 47             'fields' => $fields,
 48             'delete_form' => $deleteForm->createView(),
 49         ));
 50     }
 51 
 52     /**
 53      * when edit user action
 54      *
 55      * @return Response|\Symfony\Component\HttpFoundation\RedirectResponse|\Symf\
 56 ony\Component\HttpFoundation\Response
 57      */
 58     protected function editUserAction()
 59     {
 60         $this->dispatch(EasyAdminEvents::PRE_EDIT);
 61         $id = $this->request->query->get('id');
 62         $easyadmin = $this->request->attributes->get('easyadmin');
 63         $entity = $easyadmin['item'];
 64 
 65         if ($this->request->isXmlHttpRequest() && $property = $this->request->qu\
 66 ery->get('property')) {
 67             $newValue = 'true' === strtolower($this->request->query->get('newVal\
 68 ue'));
 69             $fieldsMetadata = $this->entity['list']['fields'];
 70 
 71             if (!isset($fieldsMetadata[$property]) || 'toggle' !== $fieldsMetada\
 72 ta[$property]['dataType']) {
 73                 throw new \RuntimeException(sprintf('The type of the "%s" proper\
 74 ty is not "toggle".', $property));
 75             }
 76 
 77             $this->updateEntityProperty($entity, $property, $newValue);
 78 
 79             return new Response((string)$newValue);
 80         }
 81 
 82         $fields = $this->entity['edit']['fields'];
 83 
 84         $editForm = $this->createEditForm($entity, $fields);
 85         if (!$this->isGranted('ROLE_SUPER_ADMIN')) {
 86             $editForm->remove('enabled');
 87             $editForm->remove('roles');
 88             $editForm->remove('locked');
 89         }
 90 
 91         $deleteForm = $this->createDeleteForm($this->entity['name'], $id);
 92 
 93         $editForm->handleRequest($this->request);
 94         if ($editForm->isValid()) {
 95             $this->preUpdateUserEntity($entity);
 96             $this->em->flush();
 97 
 98             $refererUrl = $this->request->query->get('referer', '');
 99 
100             return !empty($refererUrl)
101                 ? $this->redirect(urldecode($refererUrl))
102                 : $this->redirect($this->generateUrl('easyadmin', array('action'\
103  => 'show', 'entity' => $this->entity['name'], 'id' => $id)));
104         }
105 
106         return $this->render($this->entity['templates']['edit'], array(
107             'form' => $editForm->createView(),
108             'entity_fields' => $fields,
109             'entity' => $entity,
110             'delete_form' => $deleteForm->createView(),
111         ));
112     }
113 
114     public function createNewUserEntity()
115     {
116         return $this->get('fos_user.user_manager')->createUser();
117     }
118 
119     public function prePersistUserEntity($user)
120     {
121         $this->get('fos_user.user_manager')->updateUser($user, false);
122     }
123 
124     public function preUpdateUserEntity($user)
125     {
126         $this->get('fos_user.user_manager')->updateUser($user, false);
127     }
128 
129     /**
130      * Show Page List page
131      *
132      * @return \Symfony\Component\HttpFoundation\Response
133      */
134     public function listPageAction()
135     {
136         $this->dispatch(EasyAdminEvents::PRE_LIST);
137 
138         $rootMenuItems = $this->getDoctrine()->getRepository('AppBundle\Entity\P\
139 age')->findParent();
140 
141         return $this->render('@EasyAdmin/Page/list.html.twig', array(
142             'tree' => $rootMenuItems,
143         ));
144     }
145 
146     /**
147      * save page meta
148      *
149      * @param PageMeta $pageMeta
150      */
151     public function prePersistPageMetaEntity(PageMeta $pageMeta)
152     {
153         if ( $this->em->getRepository('AppBundle\Entity\PageMeta')->findPageMeta\
154 ByLocale( $pageMeta->getPage(), $pageMeta->getLocale() ) ) {
155             throw new \RuntimeException($this->get('translator')->trans('one_loc\
156 ale_per_pagemeta_only', array(), 'BpehNestablePageBundle') );
157         }
158 
159     }
160 
161     /**
162      * edit page meta
163      *
164      * @return Response|\Symfony\Component\HttpFoundation\RedirectResponse|\Symf\
165 ony\Component\HttpFoundation\Response
166      */
167     protected function editPageMetaAction()
168     {
169         $this->dispatch(EasyAdminEvents::PRE_EDIT);
170 
171         $id = $this->request->query->get('id');
172         $easyadmin = $this->request->attributes->get('easyadmin');
173         $entity = $easyadmin['item'];
174 
175         // get id before submission
176         $pageMeta = $this->em->getRepository('AppBundle\Entity\PageMeta')->find(\
177 $id);
178         $origId = $pageMeta->getPage()->getId();
179         $origLocale = $pageMeta->getLocale();
180 
181         if ($this->request->isXmlHttpRequest() && $property = $this->request->qu\
182 ery->get('property')) {
183             $newValue = 'true' === strtolower($this->request->query->get('newVal\
184 ue'));
185             $fieldsMetadata = $this->entity['list']['fields'];
186 
187             if (!isset($fieldsMetadata[$property]) || 'toggle' !== $fieldsMetada\
188 ta[$property]['dataType']) {
189                 throw new \RuntimeException(sprintf('The type of the "%s" proper\
190 ty is not "toggle".', $property));
191             }
192 
193             $this->updateEntityProperty($entity, $property, $newValue);
194 
195             return new Response((string) $newValue);
196         }
197 
198         $fields = $this->entity['edit']['fields'];
199 
200         $editForm = $this->createEditForm($entity, $fields);
201         $deleteForm = $this->createDeleteForm($this->entity['name'], $id);
202 
203         $editForm->handleRequest($this->request);
204         if ($editForm->isValid()) {
205             $this->dispatch(EasyAdminEvents::PRE_UPDATE, array('entity' => $enti\
206 ty));
207 
208             // if page and local is the same, dont need to check locale count
209             if ($origLocale == $entity->getLocale() && $origId == $entity->getPa\
210 ge()->getId()) {
211                 // all good
212             }
213             elseif ( $this->em->getRepository('AppBundle\Entity\PageMeta')->find\
214 PageMetaByLocale( $pageMeta->getPage(), $pageMeta->getLocale(), true ) ) {
215                 throw new \RuntimeException($this->get('translator')->trans('one\
216 _locale_per_pagemeta_only', array(), 'BpehNestablePageBundle') );
217             }
218 
219             $this->em->flush();
220 
221             $this->dispatch(EasyAdminEvents::POST_UPDATE, array('entity' => $ent\
222 ity));
223 
224             $refererUrl = $this->request->query->get('referer', '');
225 
226             return !empty($refererUrl)
227                 ? $this->redirect(urldecode($refererUrl))
228                 : $this->redirect($this->generateUrl('easyadmin', array('action'\
229  => 'list', 'entity' => $this->entity['name'])));
230         }
231 
232         $this->dispatch(EasyAdminEvents::POST_EDIT);
233 
234         return $this->render($this->entity['templates']['edit'], array(
235             'form' => $editForm->createView(),
236             'entity_fields' => $fields,
237             'entity' => $entity,
238             'delete_form' => $deleteForm->createView(),
239         ));
240     }
241 }

Let us create the list view. We have to recreate it because we are extending it from the easyadmin layout instead.

1 -> mkdir -p app/Resources/EasyAdminBundle/views/Page
2 -> touch app/Resources/EasyAdminBundle/views/Page/list.html.twig

and the contents of list.html.twig ``` # app/Resources/EasyAdminBundle/views/Page/list.html.twig

1 {{ parent() }}
2 <link rel="stylesheet" href="{{ asset('bundles/bpehnestablepage/css/styles.css '\
3 ) }}">
 1 {{ parent() }}
 2 <script src="{{ asset('bundles/bpehnestablepage/js/jquery.nestable.js') }}"></sc\
 3 ript>
 4 <script>
 5     $(function() {
 6 
 7         var before = null, after = null;
 8 
 9         $('.dd').nestable({
10             afterInit: function ( event ) { }
11         });
12 
13         $('.dd').nestable('collapseAll');
14         before = JSON.stringify($('.dd').nestable('serialize'));
15         $('.dd').on('dragEnd', function(event, item, source, destination, positi\
16 on) {
17 
18             id = item.attr('data-id');
19             parentId = item.closest('li').parent().closest('li').attr('data-id');
20 
21             // if parent id is null of if parent id and id is the same, it is th\
22 e top level.
23             parentId = (parentId == id || typeof(parentId)  === "undefined") ?  \
24 '' : parentId;
25 
26             after = JSON.stringify($('.dd').nestable('serialize'));
27 
28             token = '{{ csrf_token("bpeh_page_reorder") }}';
29 
30             if (before != after) {
31                 $.ajax({
32                     type: "POST",
33                     url: "{{ path('bpeh_page_reorder') }}",
34                     data: { id: id, parentId: parentId, position: position, csrf\
35 : token },
36                     success: function (data, dataType) {
37                         if (data.success) {
38                             $('.alert').addClass('alert-success');
39                         }
40                         else {
41                             $('.alert').addClass('alert-danger');
42                         }
43                         $('.alert').html(data.message);
44                         $('.alert').fadeTo( 0 , 1, function() {});
45                         $('.alert').fadeTo( 4000 , 0, function() {});
46                     },
47 
48                     error: function (XMLHttpRequest, textStatus, errorThrown) {
49                         console.log(XMLHttpRequest);
50                     }
51                 });
52                 before = after;
53             }
54         });
55     });
56 </script>
 1 <div class="alert alert-dismissable">
 2     {{ 'flash_reorder_instructions' | trans({}, 'BpehNestablePageBundle') }}
 3 </div>
 4 
 5 <button type="button" onclick="$('.dd').nestable('expandAll')">{{ 'expand_all'|t\
 6 rans({}, 'BpehNestablePageBundle') }}</button>
 7 <button type="button" onclick="$('.dd').nestable('collapseAll')">{{ 'collapse_al\
 8 l'|trans({}, 'BpehNestablePageBundle') }}</button>
 9 <button onclick=window.location="{{ path('easyadmin') }}?entity=Page&action=new"\
10 >{{ 'new_page'|trans({}, 'BpehNestablePageBundle') }}</button>
11 	<button onclick=window.location="{{ path('easyadmin') }}?entity=PageMeta&action\
12 =new">{{ 'new_pagemeta'|trans({}, 'BpehNestablePageBundle') }}</button>
13 <div id="nestable" class="dd">
14     <ol class="dd-list">
15         {% include "@BpehNestablePage/Page/tree.html.twig" with { 'tree':tree } \
16 %}
17     </ol>
18 </div>