Table of Contents
- Preface
- Introduction
- Chapter 1: Survival Skills
- Chapter 2: What is SongBird
- Chapter 3: Creating the Dev Environment
- Chapter 4: The Testing Framework Part 1 (Optional)
- Chapter 5: The Testing Framework Part 2 (Optional)
- Chapter 6: The User Management System Part 1
- Chapter 7: The User Management System Part 2
- Chapter 8: Doctrine Fixtures and Migrations
- Chapter 9: The Admin Panel Part 1
- Chapter 10: BDD With Codeception (Optional)
- Chapter 11: Customising the Login Process
- Chapter 12: The Admin Panel Part 2
- Chapter 13: Internalisation
- Chapter 14: Uploading Files
- Chapter 15: Logging User Activities
- Chapter 16: Improving Performance and Troubleshooting
- Chapter 17: The Page Manager Part 1
- Chapter 18: Making Your Bundle Reusable
- Chapter 19: The Page Manager Part 2
- Chapter 20: The Front View
- Chapter 21: Dependency Injection Revisited
- Final Chapter: Conclusion
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
- Get the symfony command line
- Install docker
- Install Docker-compose
- We can now fire up docker containers
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:
1024
x768
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
- EasyAdminBundle
- EasyAdminBundle and FOSUserBundle Integration
- EasyAdminBundle views configuration
- Event Listener and Subscribers
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:
1024
x768
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:
- User goes to the forget password page.
- User enters the username or email.
- User gets an email a reset link.
- User clicks on the email and goes to a password reset page.
- User enters the new password and click submit.
- 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:
1024
x768
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
- Twig Templating
- Overriding FOSUserBundle Templates
- FOSUserBundle Emails
- Codeception Mailcatcher Module
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

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
- Symfony translations
- Translations best practices
- Sticky Session
- Kernel Events
- EasyAdmin Translations
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:
1024
x768
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.

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
:
600
p
\
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
:
13
p
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
(
45
deg
,
#fff
25%
,
transparent
25%
,
\
43
transparent
75%
,
#fff
75%
,
#fff
)
,
44
-
webkit
-
linear
-
gradient
(
45
deg
,
#fff
25%
,
transparent
25%
,
\
45
transparent
75%
,
#fff
75%
,
#fff
);
46
background-image
:
-
moz
-
linear
-
gradient
(
45
deg
,
#fff
25%
,
transparent
25%
,
\
47
transparent
75%
,
#fff
75%
,
#fff
)
,
48
-
moz
-
linear
-
gradient
(
45
deg
,
#fff
25%
,
transparent
25%
,
\
49
transparent
75%
,
#fff
75%
,
#fff
);
50
background-image
:
linear
-
gradient
(
45
deg
,
#fff
25%
,
transparent
25%
,
\
51
transparent
75%
,
#fff
75%
,
#fff
)
,
52
linear
-
gradient
(
45
deg
,
#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
- Define relationship between abstract classes and interfaces
- Short guide to licenses
- Software licenses at a glance
- Composer Schema
- Composer versioning
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>
1
and don't forget about tree.html.twig
# app/Resources/EasyAdminBundle/views/Page/tree.html.twig
1
<li
class=
'dd-item'
data-id=
'
{{
v.getId
()
}}
'
>
2
<div
class=
'dd-handle'
>
3
<a
class=
"dd-nodrag"
href=
"
{{
path
(
'easyadmin'
)
}}
?entity=
{{
_entity_con
\
4
fig.name
}}
&action=edit&
{{
_entity_config.primary_key_field_name
}}
=
{{
v.getId
()
\
5
}}
"
>
{{
v.getSlug
()
}}
</a>
6
</div>
7
8
{%
set
children
=
v.getChildren
()|
length
%}
9
{%
if
children
>
0
%}
10
<ol
class=
'dd-list'
>
11
{%
include
"@EasyAdmin/BpehNestablePageBundle:Page:tree.html.twig"
w
\
12
ith
{
'tree'
:
v.getChildren
()
}
%}
13
</ol>
14
{%
endif
%}
15
</li>
1
Finally - the easyadmin config. We do not want to display the pagemeta menu.
# app/config/easyadmin/design.yml
easy_admin: design: brand_color: ‘#337ab7’ assets: css: - /bundles/app/css/style.css menu: - { entity: ‘User’, icon: ‘user’ } - { entity: ‘Page’, icon: ‘file’ } - { entity: ‘UserLog’, icon: ‘database’ } ```
and
1
# app/config/easyadmin/page.yml
2
3
easy_admin:
4
entities:
5
Page:
6
class: AppBundle\Entity\Page
7
label: admin.link.page_management
8
# for new page
9
new:
10
fields:
11
- slug
12
- isPublished
13
- sequence
14
- parent
15
edit:
16
fields:
17
- slug
18
- isPublished
19
- sequence
20
- parent
21
- pageMetas
22
show:
23
fields:
24
- id
25
- slug
26
- isPublished
27
- sequence
28
- parent
29
- modified
30
- created
31
- pageMetas
32
list:
33
actions: ['show', 'edit', 'delete']
34
fields:
35
- id
36
- slug
37
- isPublished
38
- sequence
39
- parent
40
- modified
41
PageMeta:
42
class: AppBundle\Entity\PageMeta
43
form:
44
fields:
45
- page_title
46
- menu_title
47
- { property: 'locale', type: 'AppBundle\Form\LocaleType' }
48
- { type: 'divider' }
49
- { property: 'featuredImageFile', type: 'vich_image' }
50
- { property: 'short_description', type: 'ckeditor' }
51
- { property: 'content', type: 'ckeditor' }
52
- page
Noticed the new field type we have used, ie ckeditor, vich_image, and AppBundleFormLocaleType. EasyAdminBundle has internal support for ckeditor and vich_image but AppBundleFormLocaleType is our own custom form selector which will be discussed in the next section.
Creating Custom Locale Selector Form Type
If you are looking at the pagemeta page, say http://songbird.app:8000/app_dev.php/admin/?entity=PageMeta&action=new for example, you should have noticed by now that user can enter anything under the locale textbox. What if we want to load only the languages that we defined in the config file (ie, english and french)? It is a good idea to create our own dropdown.
1
#
src
/
AppBundle
/
Form
/
LocaleType
.php
2
3
namespace
AppBundle
\
Form
;
4
5
use
Symfony
\
Component
\
Form
\
AbstractType
;
6
use
Symfony
\
Component
\
OptionsResolver
\
OptionsResolver
;
7
use
Symfony
\
Component
\
Form
\
Extension
\
Core
\
Type
\
ChoiceType
;
8
9
class
LocaleType
extends
AbstractType
10
{
11
private
$
localeChoices
;
12
13
public
function
__construct
(
array
$
localeChoices
)
14
{
15
foreach
(
$
localeChoices
as
$
v
)
{
16
$
this
->
localeChoices
[
$v
]
=
$
v
;
17
}
18
}
19
20
public
function
configureOptions
(
OptionsResolver
$
resolver
)
21
{
22
$
resolver
->
setDefaults
(
array
(
23
'choices'
=>
$
this
->
localeChoices
,
24
));
25
}
26
27
public
function
getParent
()
28
{
29
return
ChoiceType
::
class
;
30
}
31
32
}
The array localChoices is passed into the constructor. This class can be lazy loaded if we define it in service.yml
1
# src/AppBundle/Resources/config/services.yml
2
...
3
app.form.type.locale:
4
class: AppBundle\Form\LocaleType
5
arguments:
6
- "%supported_lang%"
7
tags:
8
- { name: form.type }
9
...
See how we pass the supported_lang config variable into the class? Now, go to any pagemeta new or edit page (ie http://songbird.app:8000/app_dev.php/admin/?entity=PageMeta&action=new for example) and you should see the locale dropdown updated to only 2 enties.
Let us update the translation files
1
# src/AppBundle/Resources/translations/app.en.xlf
2
...
3
<trans-unit
id=
"7"
>
4
<source>
admin.page_management</source>
5
<target>
Page Management</target>
6
</trans-unit>
7
..
and the french version
1
# src/AppBundle/Resources/translations/app.fr.xlf
2
...
3
<trans-unit
id=
"7"
>
4
<source>
admin.page_management</source>
5
<target>
Gestion de la page</target>
6
</trans-unit>
7
...
There were db changes. remember to run doctrine migrations
1
-> ./scripts/console doctrine:migrations:diff
Update BDD Tests (Optional)
Let us create the cest files,
1
-> ./vendor/bin/codecept generate:cest -c src/AppBundle acceptance As_An_Admin/I\
2
WantToManagePages
3
-> ./vendor/bin/codecept generate:cest -c src/AppBundle acceptance As_Test1_User\
4
/IDontWantToManagePages
Create the test cases from the scenarios above and make sure all your tests passes before moving on.
Remember to commit all your code before moving on to the next chapter.
Summary
In this chapter, we have extended our NestablePageBundle in EasyAdmin. We have installed CKEditor in our textarea and created a customised locale dropdown based on values from our config.yml file. Our CMS is looking more complete now.
Exercises
- From the debug toolbar, update the missing translations.
- TinyMCE is also a widely used WYSIWYG editor. How do you integrate it in Sonata Media?
- What if you want to add a new user field to the Page Management System? What is going to happen to the page if the user is deleted?
- Can you make inserting pagemeta easier for every new page added? This just shows how much thought one person need to put when creating a software.
References
Chapter 20: The Front View
Going to “http://songbird.app:8000/” has nothing at the moment because we have so far been focusing on the the admin area and not touched the frontend. In this chapter, we will create an automatic route based on the slug and display the frontend view when the slug matches. Any route that matches “/” and “/home” will be using the index template while the rest of the pages will be using the view template.
We will create a simple home and subpages using bootstrap and use smartmenus javascript library to create the top menu which will render the the submenus as well.
Lastly, we’ll add a language toggle so that the page can render different languages easily. The menu and page content will be rendered based on the toggled language. To get the menu to display different languages, we will create a custom twig function (an extension called MenuLocaleTitle).
Define User Stories
20. Frontend
Story Id | As a | I | So that I |
20.1 | test3 user | want to browse the frontend | I can get the information I want. |
Story ID 20.1: As test3 user, I want to browse the frontend, so that I can get the information I want.
Scenario Id | Given | When | Then |
20.11 | Home page is working | I go to the / or /home | I can see the jumbotron class and the text “SongBird CMS Demo” |
20.12 | Menus are working | I mouseover the about menu | I should see 2 menus under the about menu |
20.13 | Subpages are working | I click on contact memu | I should see the text “This project is hosted in” |
20.14 | Login menu is working | I click on login memu | I should see 2 menu items only |
Creating the Frontend
Let create a new frontend controller
1
#
src/AppBundle/Controller/FrontendController.php
2
3
namespace AppBundle\Controller;
4
5
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
6
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
7
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Template;
8
use Symfony\Component\HttpFoundation\Request;
9
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
10
11
/**
12
* Class FrontendController
13
* @package AppBundle\Controller
14
*/
15
class FrontendController extends Controller
16
{
17
/**
18
* @Route("/
{
slug}", name="app_frontend_index", requirements =
{
"slug" = "^(\
19
(|home)
$
)"})
20
* @Template()
21
* @Method("GET")
22
* @param Request
$
request
23
*
24
* @return array
25
*/
26
public function indexAction(Request
$
request
)
27
{
28
$
slug
=
$
request
->get('_route_params')['slug'];
29
$
slug
= (
$
slug
) ?: 'home';
30
$
page
=
$
this
->getDoctrine()->getRepository('AppBundle:Page')->findOneBy\
31
Slug(
$
slug
);
32
$
pagemeta
=
$
this
->getDoctrine()->getRepository('AppBundle:PageMeta')->f\
33
indPageMetaByLocale(
$
page
,
$
request
->getLocale());
34
$
rootMenuItems
=
$
this
->getDoctrine()->getRepository('AppBundle:Page')->\
35
findParent();
36
return array(
37
'pagemeta' =>
$
pagemeta
,
38
'tree' =>
$
rootMenuItems
,
39
);
40
}
41
/**
42
* @Route("/
{
slug}", name="app_frontend_view")
43
* @Template()
44
* @Method("GET")
45
*/
46
public function pageAction(Request
$
request
)
47
{
48
$
page
=
$
this
->getDoctrine()->getRepository('AppBundle:Page')->findOneBy\
49
Slug(
$
request
->get('_route_params')['slug']);
50
$
pagemeta
=
$
this
->getDoctrine()->getRepository('AppBundle:PageMeta')->f\
51
indPageMetaByLocale(
$
page
,
$
request
->getLocale());
52
$
rootMenuItems
=
$
this
->getDoctrine()->getRepository('AppBundle:Page')->\
53
findParent();
54
return array(
55
'pagemeta' =>
$
pagemeta
,
56
'tree' =>
$
rootMenuItems
,
57
);
58
}
59
}
All routing magic can be done with the @Route annotation (we can even use regex as shown in the indexAction). With the new @Template annotation, the action just need to return an array rather than a response. With the new routes added, we will move the frontend routes to the last priority, so routes like /login will be executed first.
1
# app/config/routing.yml
2
...
3
fos_user_security:
4
resource: "@FOSUserBundle/Resources/config/routing/security.xml"
5
6
fos_user_resetting:
7
resource: "@FOSUserBundle/Resources/config/routing/resetting.xml"
8
prefix: /resetting
9
10
frontend:
11
resource: "@AppBundle/Controller/FrontendController.php"
12
type: annotation
Let us update the frontend base view.
1
# src/AppBundle/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
(
'minified/css/styles.css'
)
}}
"
rel=
"stylesheet"
/>
12
{%
endblock
%}
13
</head>
14
<body>
15
16
{%
set
urlPrefix
=
(
app.environment
==
'dev'
)
?
'/app_dev.php'
:
''
%}
17
18
{%
block
body
%}
19
20
<div
class=
"container"
>
21
{%
block
topnav
%}
22
<ul
id=
"top_menu"
class=
"sm sm-clean"
>
23
<li
id=
"page_logo"
>
24
<a
href=
"
{{
urlPrefix
}}
"
alt=
"songbird"
>
25
<img
src=
"
{{
asset
(
'bundles/app/images/logo.png'
)
}}
"
cl\
26
ass=
"center-block img-responsive"
alt=
"Songbird"
/>
27
</a>
28
</li>
29
{%
if
tree
is
defined
%}
30
{%
include
"AppBundle:Frontend:tree.html.twig"
with
{
'tree'
\
31
:
tree
}
%}
32
{%
endif
%}
33
<li
id=
"frontend_lang_toggle"
>
34
<select
id=
"lang"
name=
"lang"
>
35
{%
for
lang
in
supported_lang
%}
36
<option
value=
"
{{
lang
}}
"
>
{{
lang
}}
</option>
37
{%
endfor
%}
38
</select>
39
</li>
40
<li
id=
"login_link"
>
41
{%
if
is_granted
(
"IS_AUTHENTICATED_REMEMBERED"
)
%}
42
<a
href=
"
{{
path
(
'fos_user_security_logout'
)
}}
"
>
43
{{
'layout.logout'
|
trans
({},
'FOSUserBundle'
)
}}
44
</a>
45
{%
else
%}
46
<a
href=
"
{{
path
(
'fos_user_security_login'
)
}}
"
>
47
{{
'layout.login'
|
trans
({},
'FOSUserBundle'
)
}}
48
</a>
49
{%
endif
%}
50
</li>
51
</ul>
52
53
{%
endblock
%}
54
55
<div
class=
"clearfix vspace"
></div>
56
57
{%
block
content
%}{%
endblock
%}
58
59
{%
block
footer
%}
60
<hr
/>
61
<footer>
62
<p
class=
"text-center"
>
© Songbird {{
"now"
|
date
(
"Y"
)
}}
</p>
63
</footer>
64
{%
endblock
%}
65
</div>
66
67
{%
endblock
%}
68
69
{%
block
script
%}
70
<script
src=
"
{{
asset
(
'minified/js/javascript.js'
)
}}
"
></script>
71
<script>
72
$(function() {
73
$('#top_menu').smartmenus();
74
// select the box based on locale
75
$('#lang').val('{{
app.request.getLocale
()
}}
');
76
// redirect user if user change locale
77
$('#lang').change(function() {
78
window.location='{{
urlPrefix
}}
'+$(this).val()+'/locale';
79
});
80
});
81
</script>
82
{%
endblock
%}
83
84
</body>
85
</html>
We will now create a homepage view.
1
# app/Resources/AppBundle/views/Frontend/index.html.twig
2
3
{%
extends
"base.html.twig"
%}
4
5
{%
block
title
%}
6
{{
pagemeta.getPageTitle
()
}}
7
{%
endblock
%}
8
9
{%
block
content
%}
10
{%
if
pagemeta
is
not
null
%}
11
<div
class=
"jumbotron"
>
12
<h1
class=
"text-center"
>
{{
pagemeta.getShortDescription
()
|
raw
}}
</h1>
13
</div>
14
15
<div
class=
"row-fluid"
>
16
<div
class=
"pull-left col-xs-3 col-md-3"
>
17
{%
if
pagemeta.featuredImage
is
not
null
%}
18
<img
class=
"featured_image"
src=
"
{{
vich_uploader_asset
(
pagemeta
,
'featuredI\
19
mageFile'
)
}}
"
alt=
"
{{
pagemeta.getShortDescription
()
|
striptags
}}
"
/>
20
{%
endif
%}
21
</div>
22
<div
class=
"pull-right col-xs-9 col-md-9"
>
23
{{
pagemeta.getContent
()
|
raw
}}
24
</div>
25
</div>
26
<div
class=
"clearfix"
></div>
27
28
{%
endif
%}
29
{%
endblock
%}
and pages view
1
# app/Resources/AppBundle/views/Frontend/page.html.twig
2
3
{%
extends
"base.html.twig"
%}
4
5
{%
block
title
%}
6
{{
pagemeta.getPageTitle
()
}}
7
{%
endblock
%}
8
9
{%
block
content
%}
10
11
{%
if
pagemeta
is
not
null
%}
12
<h2>
{{
pagemeta.getShortDescription
()
|
raw
}}
</h2>
13
14
{%
if
pagemeta.featuredImage
is
not
null
%}
15
<img
class=
"featured_image"
src=
"
{{
vich_uploader_asset
(
pagemeta
,
'featuredIma\
16
geFile'
)
}}
"
alt=
"
{{
pagemeta.getShortDescription
()
|
striptags
}}
"
/>
17
{%
endif
%}
18
19
{{
pagemeta.getContent
()
|
raw
}}
20
21
{%
endif
%}
22
23
{%
endblock
%}
and lastly, recursive view for the menu
1
# app/Resources/AppBundle/views/Frontend/tree.html.twig
2
3
{%
for
v
in
tree
%}
4
5
<li>
6
<a
href=
"
{{
urlPrefix
}}
/
{{
v.getSlug
()
}}
"
>
{{
getMenuLocaleTitle
(
v.getS
\
7
lug
())
}}
</a>
8
{%
set
children
=
v.getChildren
()|
length
%}
9
{%
if
children
>
0
%}
10
<ul>
11
{%
include
"AppBundle:Frontend:tree.html.twig"
with
{
'tree'
:
v.g
\
12
etChildren
()
}
%}
13
</ul>
14
{%
endif
%}
15
</li>
16
{%
endfor
%}
Note the new getMenuLocaleTitle function in the twig. We will create a custom function usable by twig - Twig Extension.
1
#
src
/
AppBundle
/
Twig
/
Extension
/
MenuLocaleTitle
.php
2
3
namespace
AppBundle
\
Twig
\
Extension
;
4
5
/**
6
* Twig Extension to get Menu title based on locale
7
*/
8
class
MenuLocaleTitle
extends
\
Twig_Extension
9
{
10
/**
11
* @var EntityManager
12
*/
13
private
$
em
;
14
15
/**
16
* @var $request
17
*/
18
private
$
request
;
19
20
/**
21
* MenuLocaleTitle constructor.
22
*
23
* @param $em
24
* @param $request
25
*/
26
public
function
__construct
(
$
em
,
$
request
)
27
{
28
$
this
->
em
=
$
em
;
29
$
this
->
request
=
$
request
->
getCurrentRequest
();
30
}
31
32
/**
33
* @return string
34
*/
35
public
function
getName
()
36
{
37
return
'menu_locale_title_extension'
;
38
}
39
40
/**
41
* @return array
42
*/
43
public
function
getFunctions
()
44
{
45
return
array
(
46
new
\
Twig_SimpleFunction
(
'getMenuLocaleTitle'
,
array
(
$
this
,
'getMenu\
47
LocaleTitle'
))
48
);
49
}
50
51
/**
52
* @param string $slug
53
*
54
* @return mixed
55
*/
56
public
function
getMenuLocaleTitle
($
slug
=
'home'
)
57
{
58
$
locale
=
(
$
this
->
request
)
?
$
this
->
request
->
getLocale
()
:
'en'
;
59
$
page
=
$
this
->
em
->
getRepository
(
'AppBundle:Page'
)
->
findOneBySlug
(
$
slug
);
60
$
pagemeta
=
$
this
->
em
->
getRepository
(
'AppBundle:PageMeta'
)
->
findPageMetaByL
\
61
ocale
(
$
page
,
$
locale
);
62
63
return
$
pagemeta
->
getMenuTitle
();
64
}
65
}
we now need to make this class available as a service.
1
# src/AppBundle/Resources/config/services.yml
2
...
3
menu_locale_title.twig_extension:
4
class: AppBundle\Twig\Extension\MenuLocaleTitle
5
arguments:
6
- "@doctrine.orm.entity_manager"
7
- "@request_stack"
8
tags:
9
- { name: twig.extension }
10
...
Since we have added a new top navbar, let us remove the SongBird logo from the login and password reset pages. Update the following pages as you see fit:
1
/songbird/symfony/app/Resources/FOSUserBundle/views/Resetting/checkEmail.html.tw\
2
ig
3
/songbird/symfony/app/Resources/FOSUserBundle/views/Resetting/request.html.twig
4
/songbird/symfony/app/Resources/FOSUserBundle/views/Resetting/reset.html.twig
5
/songbird/symfony/app/Resources/FOSUserBundle/views/Security/login.html.twig
Let us update bower.json to pull in smartmenus js.
1
-> bower install smartmenus --S
then make gulp to pull the libraries in
1
# gulpfile.js
2
...
3
// Minify JS
4
gulp.task('js', function () {
5
return gulp.src(['bower_components/jquery/dist/jquery.js',
6
'bower_components/bootstrap/dist/js/bootstrap.js',
7
'bower_components/smartmenus/dist/jquery.smartmenus.js'])
8
.pipe(concat('javascript.js'))
9
.pipe(uglify())
10
.pipe(sourcemaps.write('./'))
11
.pipe(gulp.dest('web/minified/js'));
12
});
13
14
// Minify CSS
15
gulp.task('css', function () {
16
return gulp.src([
17
'bower_components/bootstrap/dist/css/bootstrap.css',
18
'bower_components/smartmenus/dist/css/sm-core-css.css',
19
'bower_components/smartmenus/dist/css/sm-clean/sm-clean.css',
20
'src/AppBundle/Resources/public/less/*.less',
21
'src/AppBundle/Resources/public/sass/*.scss',
22
'src/AppBundle/Resources/public/css/*.css'])
23
.pipe(gulpif(/[.]less/, less()))
24
.pipe(gulpif(/[.]scss/, sass()))
25
.pipe(concat('styles.css'))
26
.pipe(uglifycss())
27
.pipe(sourcemaps.write('./'))
28
.pipe(gulp.dest('web/minified/css'));
29
});
30
...
Let us update the datafixtures as well.
1
# src/AppBundle/DataFixtures/ORM/LoadPageData.php
2
3
...
4
$homemetaEN = new PageMeta();
5
$homemetaEN->setPage($homepage);
6
$homemetaEN->setMenuTitle('Home');
7
$homemetaEN->setPageTitle('SongBird CMS Demo');
8
$homemetaEN->setShortDescription('SongBird CMS Demo');
9
$homemetaEN->setContent('<p>
SongBird is a simple CMS built with popular \
10
bundles like FOSUserBundle and EasyAdminBundle.
11
The CMS is meant to showcase Rapid Application Development with Symf\
12
ony.</p>
');
13
copy(__DIR__.'/images/home_en.png', __DIR__.'/../../../../web/uploads/fe\
14
atured_images/home_en.png');
15
$homemetaEN->setFeaturedImage('home_en.png');
16
$manager->persist($homemetaEN);
17
18
$homemetaFR = new PageMeta();
19
$homemetaFR->setPage($homepage);
20
$homemetaFR->setMenuTitle('Accueil');
21
$homemetaFR->setPageTitle('SongBird CMS Démo');
22
$homemetaFR->setShortDescription('SongBird CMS Démo');
23
$homemetaFR->setLocale('fr');
24
$homemetaFR->setContent('<p>
SongBird est un simple CMS construit avec de\
25
s faisceaux populaires comme FOSUserBundle et EasyAdminBundle.
26
Le CMS est destinée à mettre en valeur Rapid Application Development\
27
avec Symfony .</p>
');
28
copy(__DIR__.'/images/home_fr.png', __DIR__.'/../../../../web/uploads/fe\
29
atured_images/home_fr.png');
30
$homemetaFR->setFeaturedImage('home_fr.png');
31
$manager->persist($homemetaFR);
I’ve added new images to the homepage. The new images are in the src/AppBundle/DataFixtures/ORM/images folder. Feel free to get the images from there.
Lastly, let us update the stylesheets. We might as well update them in scss
1
#
src
/
AppBundle
/
Resources
/
public
/
sass
/
styles
.scss
2
3
//
variables
4
$
black
:
#000
;
5
$
white
:
#fff
;
6
$
radius
:
6px
;
7
$
spacing
:
20px
;
8
$
font_big
:
16px
;
9
$
featured_image_width
:
200px
;
10
11
body
{
12
color
:
$
black
;
13
background
:
$
white
!important
;
14
padding-top
:
$
spacing
;
15
}
16
.vspace
{
17
height
:
$
spacing
;
18
}
19
.skin-black
{
20
.
logo
{
21
background-color
:
$
black
;
22
}
23
.left-side
{
24
background-color
:
$
black
;
25
}
26
}
27
.sidebar
{
28
ul
{
29
padding-top
:
$
spacing
;
30
}
31
li
{
32
padding-top
:
$
spacing
;
33
}
34
}
35
.admin_top_left
{
36
padding-top
:
$
spacing
0
0
$
spacing
;
37
}
38
#top_menu
{
39
padding
:
$
spacing
0
$
spacing
;
40
41
#page
_logo
{
42
padding
:
0
$
spacing
;
43
margin
:
-
$
spacing
/
2
0
;
44
}
45
46
#login_link
{
47
float
:
right
;
48
}
49
50
#frontend_lang_toggle
{
51
float
:
right
;
52
padding
:
$
spacing
/
2
$
spacing
;
53
}
54
}
55
.sm-clean
{
56
border
-
radius
:
$
radius
;
57
58
}
59
//
admin
area
60
#page_logo
img
{
61
display
:
inline
;
62
max-height
:
100%
;
63
max-width
:
50%
;
64
}
65
#page_menu
li
{
66
line-height
:
$
spacing
;
67
padding-top
:
$
spacing
;
68
}
69
.navbar-brand
img
{
70
margin-top
:
-8px
;
71
}
72
73
.form-signin
{
74
max-width
:
330px
;
75
padding
:
15px
;
76
margin
:
0
auto
;
77
.
form
-
signin
-
heading
{
78
margin-bottom
:
$
spacing
;
79
}
80
.checkbox
{
81
margin-bottom
:
$
spacing
;
82
font-weight
:
normal
;
83
}
84
.form-control
{
85
position
:
relative
;
86
height
:
auto
;
87
box
-
sizing
:
border
-
box
;
88
padding
:
$
spacing
;
89
font-size
:
$
font_big
;
90
&:
focus
{
91
z-index
:
2
;
92
}
93
}
94
input
[
type
=
"email"
]
{
95
border-bottom
-
right
-
radius
:
0
;
96
border-bottom
-
left
-
radius
:
0
;
97
}
98
input
[
type
=
"password"
]
{
99
margin-bottom
:
$
spacing
;
100
border-top
-
left
-
radius
:
0
;
101
border-top
-
right
-
radius
:
0
;
102
}
103
}
104
.form-control
{
105
margin-top
:
$
spacing
;
106
margin-bottom
:
$
spacing
;
107
}
108
109
//
frontend
110
.featured_image
{
111
margin
:
auto
;
112
display
:
block
;
113
width
:
$
featured_image_width
;
114
}
We no longer need our old .css files
1
-> git rm src/AppBundle/Resources/public/css/*
Now run gulp and refresh the homepage and everything should renders.
1
-> gulp
Remember to create the featured_images dir and reset the db if not done
1
-> mkdir -p web/uploads/featured_images
2
-> ./scripts/resetapp
Go to homepage and this should be the end result.

Update BDD (Optional)
Let us create the cest file:
1
-> vendor/bin/codecept generate:cest -c src/AppBundle acceptance As_Test3_User/I\
2
WantToViewTheFrontend
Write your test and make sure everything passes.
Summary
In this chapter, we have created the frontend controllers and views. We used smartmenus to render the menus and converted our css to sass. Finally, we wrote BDD tests to make sure our frontend renders correctly. The CMS is now complete.
Exercises
- Try extending the NestablePageBundle so that you can have multiple menus, say a top and bottom menu?
- One of the argument against using a language toggle is that it is bad for SEO. Language toggle can be good for usability. Can you think of a way to overcome the SEO issue?
References
Chapter 21: Dependency Injection Revisited
Upon reflection of what we have covered in the last 20 chapters, I think there are lots of improvements that can be done. In particular, I feel that I wouldn’t do justice to this book if I don’t give an example of Compiler Pass.
This is an advance chapter. If you skipped all the chapters and came to this chapter by chance, I recommend you to read up DI and DIC before continuing.
In this chapter, I like to introduce 2 improvements to the CMS.
a) Simplifying config.yml
b) Adding Simple User Access Control to EasyAdminBundle.
Simplifying config.yml
Due to DI, the bundle Extension is called when the bundle is being initialised. The end result is a bunch of parameters and services that can be used and referenced throughout the application.
The app/config/config.yml is read by all bundle extensions so that relevant information relating to the bundle can be extracted. So far, there are many configuration parameters like fos, vich, doctrine …etc. To make the installation easier, we could move all these extra configuration to elsewhere so that developers don’t have to worry about them when installing the CMS and it also makes the file looks cleaner.
The trick that does that is to implement the PrependExtensionInterface.
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\DependencyInjection\Extension\PrependExtensionInterface;
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 AppExtension extends Extension implements PrependExtensionInterface
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
$
loader
= new YamlFileLoader(
$
container
, new FileLocator(__DIR__ . '/../\
28
Resources/config'));
29
$
loader
->load('services.yml');
30
}
31
32
/**
33
* http://symfony.com/doc/current/bundles/prepend_extension.html
34
*/
35
public function prepend(ContainerBuilder
$
container
)
36
{
37
// doctrine config
38
$
doctrine
= [];
39
$
doctrine
['orm']['resolve_target_entities']['Bpeh\NestablePageBundle\Mod\
40
el\PageBase'] = 'AppBundle\Entity\Page';
41
$
doctrine
['orm']['resolve_target_entities']['Bpeh\NestablePageBundle\Mod\
42
el\PageMetaBase'] = 'AppBundle\Entity\PageMeta';
43
$
container
->prependExtensionConfig('doctrine',
$
doctrine
);
44
45
// fos config
46
$
fosuser
= [];
47
$
fosuser
['db_driver'] = 'orm';
48
$
fosuser
['firewall_name'] = 'main';
49
$
fosuser
['user_class'] = 'AppBundle\Entity\User';
50
$
fosuser
['from_email']['address'] = 'admin@songbird.app';
51
$
fosuser
['from_email']['sender_name'] = 'Songbird';
52
$
container
->prependExtensionConfig('fos_user',
$
fosuser
);
53
54
#
Nestable page config
55
$
page
= [];
56
$
page
['page_entity'] = 'AppBundle\Entity\Page';
57
$
page
['pagemeta_entity'] = 'AppBundle\Entity\PageMeta';
58
$
page
['page_form_type'] = 'AppBundle\Form\PageType';
59
$
page
['pagemeta_form_type'] = 'AppBundle\Form\PageMetaType';
60
$
container
->prependExtensionConfig('bpeh_nestable_page',
$
page
);
61
62
#
Vich config
63
$
vich
= [];
64
$
vich
['db_driver'] = 'orm';
65
$
vich
['mappings']['profile_images']['uri_prefix'] = '%app.profile_image.\
66
path%';
67
$
vich
['mappings']['profile_images']['upload_destination'] = '%kernel.roo\
68
t_dir%/../web/uploads/profiles';
69
$
vich
['mappings']['profile_images']['namer'] = 'vich_uploader.namer_uniq\
70
id';
71
$
vich
['mappings']['featured_image']['uri_prefix'] = '%app.featured_image\
72
.path%';
73
$
vich
['mappings']['featured_image']['upload_destination'] = '%kernel.roo\
74
t_dir%/../web/uploads/featured_images';
75
$
vich
['mappings']['featured_image']['namer'] = 'vich_uploader.namer_uniq\
76
id';
77
$
container
->prependExtensionConfig('vich_uploader',
$
vich
);
78
79
}
80
}
my config.yml then becomes like this:
1
# app/config/config.yml
2
3
imports:
4
- { resource: parameters.yml }
5
- { resource: security.yml }
6
- { resource: services.yml }
7
- {resource: easyadmin/ }
8
9
parameters:
10
locale: en
11
supported_lang: [ 'en', 'fr']
12
admin_path: admin
13
app.profile_image.path: /uploads/profiles
14
app.featured_image.path: /uploads/featured_images
15
16
framework:
17
#esi: ~
18
translator: { fallbacks: ["%locale%"] }
19
secret: "%secret%"
20
router:
21
resource: "%kernel.root_dir%/config/routing.yml"
22
strict_requirements: ~
23
form: ~
24
csrf_protection: ~
25
validation: { enable_annotations: true }
26
#serializer: { enable_annotations: true }
27
templating:
28
engines: ['twig']
29
default_locale: "%locale%"
30
trusted_hosts: ~
31
trusted_proxies: ~
32
session:
33
# handler_id set to null will use default session handler from php.ini
34
handler_id: ~
35
fragments: ~
36
http_method_override: true
37
38
# Twig Configuration
39
twig:
40
debug: "%kernel.debug%"
41
strict_variables: "%kernel.debug%"
42
globals:
43
supported_lang: '%supported_lang%'
44
45
# Doctrine Configuration
46
doctrine:
47
dbal:
48
driver: pdo_mysql
49
host: "%database_host%"
50
port: "%database_port%"
51
dbname: "%database_name%"
52
user: "%database_user%"
53
password: "%database_password%"
54
charset: UTF8
55
orm:
56
auto_generate_proxy_classes: "%kernel.debug%"
57
naming_strategy: doctrine.orm.naming_strategy.underscore
58
auto_mapping: true
59
60
# Swiftmailer Configuration
61
swiftmailer:
62
transport: "%mailer_transport%"
63
host: "%mailer_host%"
64
username: "%mailer_user%"
65
password: "%mailer_password%"
66
spool: { type: memory }
Noticed that I could have moved more parameters over to the prepend function if I want to simplify the installation further.
Adding Simple Access Control to EasyAdminBundle
I still want to comment javiereguiluz for creating the wonderful EasyAdminBundle. As of current, the bundle doesn’t support user permissions out of the box. I believe there might be plans to include this feature in the future as it is a widely requested feature.
As an exercise, let’s say that we want to customise the bundle so that we can control access to certain parts of the admin area based on the user’s role and we want to do that simply by changing the easyadmin yaml files.
Let us allow all authenticated users to access the admin area rather than just ROLE_USER.
1
# app/config/security.yml
2
...
3
access_control:
4
- { path: ^/login$, role: IS_AUTHENTICATED_ANONYMOUSLY }
5
- { path: ^/resetting, role: IS_AUTHENTICATED_ANONYMOUSLY }
6
- { path: ^/%admin_path%/, role: IS_AUTHENTICATED_FULLY }
The new design.yml should look like this:
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
9
menu:
10
- { label: 'Dashboard', route: 'dashboard', default: true }
11
- { entity: 'User', icon: 'user', role: ROLE_USER }
12
- { entity: 'Page', icon: 'file', role: ROLE_ADMIN }
13
- { entity: 'UserLog', icon: 'database', role: ROLE_ADMIN }
Noticed that we have added a new attribute called “role” to each menu item and the value (say “ROLE_ADMIN”) means the mimimum permission level required to access that menu. In this case, everyone can see the dashboard, ROLE_USER and above can access the User link and only ROLE_ADMIN can see the User and UserLog link.
We are going to do something similar for all the entities yaml, starting from the page entity
1
# app/config/easyadmin/page.yml
2
3
easy_admin:
4
entities:
5
Page:
6
class: AppBundle\Entity\Page
7
label: admin.link.page_management
8
role: ROLE_ADMIN
9
# for new page
10
new:
11
fields:
12
- slug
13
- isPublished
14
- sequence
15
- parent
16
edit:
17
fields:
18
- slug
19
- isPublished
20
- sequence
21
- parent
22
- pageMetas
23
show:
24
fields:
25
- id
26
- slug
27
- isPublished
28
- sequence
29
- parent
30
- modified
31
- created
32
- pageMetas
33
list:
34
actions: ['show', 'edit', 'delete']
35
fields:
36
- id
37
- slug
38
- isPublished
39
- sequence
40
- parent
41
- modified
42
PageMeta:
43
class: AppBundle\Entity\PageMeta
44
role: ROLE_ADMIN
45
form:
46
fields:
47
- page_title
48
- menu_title
49
- { property: 'locale', type: 'AppBundle\Form\LocaleType' }
50
- { type: 'divider' }
51
- { property: 'featuredImageFile', type: 'vich_image' }
52
- { property: 'short_description', type: 'ckeditor' }
53
- { property: 'content', type: 'ckeditor' }
54
- page
Now, the user entity:
1
# app/config/easyadmin/user.yml
2
3
easy_admin:
4
entities:
5
User:
6
class: AppBundle\Entity\User
7
label: admin.link.user_management
8
role: ROLE_USER
9
# for new user
10
new:
11
role: ROLE_ADMIN
12
fields:
13
- username
14
- firstname
15
- lastname
16
- { property: 'plainPassword', type: 'repeated', type_options:\
17
{ type: 'Symfony\Component\Form\Extension\Core\Type\PasswordType', first_option\
18
s: {label: 'Password'}, second_options: {label: 'Repeat Password'}, invalid_mess\
19
age: 'The password fields must match.'}}
20
- { property: 'email', type: 'email', type_options: { trim: tr\
21
ue } }
22
- { property: 'imageFile', type: 'vich_image' }
23
- roles
24
- enabled
25
edit:
26
role: ROLE_ADMIN
27
fields:
28
- username
29
- firstname
30
- lastname
31
- { property: 'plainPassword', type: 'repeated', type_options: {\
32
type: 'Symfony\Component\Form\Extension\Core\Type\PasswordType', required: fals\
33
e, first_options: {label: 'Password'}, second_options: {label: 'Repeat Password'\
34
}, invalid_message: 'The password fields must match.'}}
35
- { property: 'email', type: 'email', type_options: { trim: true\
36
} }
37
- { property: 'imageFile', type: 'vich_image' }
38
- roles
39
- enabled
40
show:
41
role: ROLE_ADMIN
42
fields:
43
- id
44
- { property: 'image', type: 'image', base_path: '%app.profile_i\
45
mage.path%'}
46
- username
47
- firstname
48
- lastname
49
- email
50
- roles
51
- enabled
52
- { property: 'last_login', type: 'datetime' }
53
- modified
54
- created
55
list:
56
role: ROLE_USER
57
title: 'User Listing'
58
actions: ['show']
59
fields:
60
- id
61
- { property: 'image', type: 'image', base_path: '%app.profile\
62
_image.path%'}
63
- username
64
- email
65
- firstname
66
- lastname
67
- enabled
68
- roles
69
- { property: 'last_login', type: 'datetime' }
70
delete:
71
role: ROLE_ADMIN
and finally - userlog.yml.
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
role: ROLE_ADMIN
9
show:
10
actions: ['list', '-edit', '-delete']
11
list:
12
actions: ['show', '-edit', '-delete']
When parameters and services are created by the extension but not yet compiled in optimised DIC, there is a chance to manipulate them. Compiler Pass exists for this purpose.
Let us tell our AppBundle to initiate its compiler pass when it is loaded by the kernel.
1
#
src
/
AppBundle
/
AppBundle
.
php
2
3
<?php
4
5
namespace
AppBundle
;
6
7
use
Symfony\Component\HttpKernel\Bundle\Bundle
;
8
use
Symfony\Component\DependencyInjection\ContainerBuilder
;
9
use
AppBundle\DependencyInjection\Compiler\ConfigPass
;
10
11
class
AppBundle
extends
Bundle
12
{
13
public
function
build
(
ContainerBuilder
$container
)
14
{
15
parent
::
build
(
$container
);
16
$container
->
addCompilerPass
(
new
ConfigPass
());
17
}
18
}
We have added a new compiler pass class called ConfigPass.php. Compiler Pass needs to extend the CompilerPassInterface.
1
#
src
/
AppBundle
/
DependencyInjection
/
Compiler
/
ConfigPass
.
php
2
3
<?php
4
5
<?
php
6
7
namespace
AppBundle\DependencyInjection\Compiler
;
8
9
use
Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface
;
10
use
Symfony\Component\DependencyInjection\ContainerBuilder
;
11
12
class
ConfigPass
implements
CompilerPassInterface
13
{
14
public
function
process
(
ContainerBuilder
$container
)
{
15
16
// $container->getParameterBag();
17
// $container->getServiceIds();
18
19
$config
=
$container
->
getParameter
(
'easyadmin.config'
);
20
21
// use menu to use IS_AUTHENTICATED_FULLY role by default if not set
22
foreach
(
$config
[
'design'
][
'menu'
]
as
$k
=>
$v
)
{
23
if
(
!
isset
(
$v
[
'role'
]))
{
24
$config
[
'design'
][
'menu'
][
$k
][
'role'
]
=
'IS_AUTHENTICATED_FULLY'
;
25
}
26
}
27
28
// update entities to use IS_AUTHENTICATED_FULLY role by default if not \
29
set
30
foreach
(
$config
[
'entities'
]
as
$k
=>
$v
)
{
31
if
(
!
isset
(
$v
[
'role'
]))
{
32
$config
[
'entities'
][
$k
][
'role'
]
=
'IS_AUTHENTICATED_FULLY'
;
33
}
34
}
35
36
// update views to use entities role by default if not set
37
foreach
(
$config
[
'entities'
]
as
$k
=>
$v
)
{
38
$views
=
[
'new'
,
'edit'
,
'show'
,
'list'
,
'form'
,
'delete'
];
39
foreach
(
$views
as
$view
)
{
40
if
(
!
isset
(
$v
[
$view
][
'role'
]))
{
41
$config
[
'entities'
][
$k
][
$view
][
'role'
]
=
$v
[
'role'
];
42
}
43
}
44
}
45
46
$container
->
setParameter
(
'easyadmin.config'
,
$config
);
47
48
}
49
}
What we have done here is to change the easyadmin.config parameter produced by the EasyAdminBundle. easyadmin.config is simply a bunch of arrays built based on the yaml config under app/config/easy_admin. Each for-loop adds a new key called “role” with the default “IS_AUTHENTICATED_FULLY” role if not specified by the config.
EasyAdmin dispatches lots of events. We were already subscribed to it.
1
# src/AppBundle/Resources/config/services.yml
2
...
3
app.subscriber:
4
class: AppBundle\EventListener\AppSubscriber
5
arguments:
6
- "@service_container"
7
tags:
8
- { name: kernel.event_subscriber }
We now need to add a bit more logic to the subscriber.
1
# src/AppBundle/EventListener/AppSubscriber.php
2
3
class
AppSubscriber
implements
EventSubscriberInterface
4
{
5
...
6
7
/**
8
* show
an
error
if
user
is
not
superadmin
and
tries
to
manage
restricted
st
\
9
uff
10
*
11
* @param
GenericEvent
$event
event
12
* @return
null
13
* @throws
AccessDeniedException
14
*/
15
public
function
checkUserRights
(GenericEvent
$event
)
16
{
17
18
// if
super
admin
, allow
all
19
$authorization
= $this-
>container-
>get
('security.authorization_check\
20
er'
);
21
$request
= $this-
>container-
>get
('request_stack'
)->getCurrentRequest
\
22
()->query
;
23
24
if
($authorization-
>isGranted
('ROLE_ADMIN'
)) {
25
return
;
26
}
27
28
$entity
= $request-
>get
('entity'
);
29
$action
= $request-
>get
('action'
);
30
$user_id
= $request-
>get
('id'
);
31
32
// allow
user
to
see
and
edit
their
own
profile
irregardless
of
perm
\
33
issions
34
if
($entity
== 'User'
) {
35
// if
edit
and
show
36
if
($action
== 'edit'
|| $action
== 'show'
) {
37
// check
user
is
himself
38
if
($user_id
== $this-
>container-
>get
('security.toke\
39
n_storage'
)->getToken
()->getUser
()->getId
()) {
40
return
;
41
}
42
}
43
}
44
45
$config
= $this-
>container-
>get
('easyadmin.config.manager'
)->getBack
\
46
endConfig
();
47
48
// check
for
permission
for
each
action
49
foreach
($config
['entities'
] as
$k
=> $v
) {
50
if
($entity
== $k
&& !$authorization-
>isGranted
($v
[$action
]['rol\
51
e'
])) {
52
throw
new
AccessDeniedException
();
53
}
54
}
55
}
56
...
We have triggered the checkUserRights function based on a few EasyAdmin events. We have allowed the logged in user to edit his own profile irregardless of role’s permission. Then, the for-loop does the magic of allowing or denying user to access different parts of the admin area based on the role key in easyadmin.config.manager service.
Note that this will work only if our AdminController dispatches the events, ie
1
#
src
/
AppBundle
/
Controller
/
AdminController
.php
2
3
...
4
/**
5
* Show Page List page
6
* @return \Symfony\Component\HttpFoundation\Response
7
*/
8
public
function
listPageAction
()
9
{
10
$
this
->
dispatch
(
EasyAdminEvents
::
PRE_LIST
);
11
...
12
13
}
The menu display is not managed by the event subscriber. We have to add an is_granted statement before rendering the menu. See below:
1
# app/Resources/views/easy_admin/menu.html.twig
2
3
{%
macro
render_menu_item
(
item
,
translation_domain
)
%}
4
{%
if
item.type
==
'divider'
%}
5
{{
item.label
|
trans
(
domain
=
translation_domain
)
}}
6
{%
else
%}
7
{%
set
menu_params
=
{
menuIndex
:
item.menu_index
,
submenuIndex
:
item.su
\
8
bmenu_index
}
%}
9
{%
set
path
=
10
item.type
==
'link'
?
item.url
:
11
item.type
==
'route'
?
path
(
item.route
,
item.params
)
:
12
item.type
==
'entity'
?
path
(
'easyadmin'
,
{
entity
:
item.entity
,
action
:
\
13
'list'
}|
merge
(
menu_params
)|
merge
(
item.params
))
:
14
item.type
==
'empty'
?
'#'
:
''
15
%}
16
17
{# if the URL generated for the route belongs to the backend, regenerate
18
the URL to include the menu_params to display the selected menu item
19
(this is checked comparing the beginning of the route URL with the ba\
20
ckend homepage URL)
21
#}
22
{%
if
item.type
==
'route'
and
(
path
starts
with
path
(
'easyadmin'
))
%}
23
{%
set
path
=
path
(
item.route
,
menu_params
|
merge
(
item.params
))
%}
24
{%
endif
%}
25
26
<a href="
{{
path
}}
"
{%
if
item.target
|
default
(
false
)
%}
target="
{{
item
.\
27
target
}}
"
{%
endif
%}
>
28
{%
if
item.icon
is
not
empty
%}
<i class="fa
{{
item.icon
}}
"></i>{% \
29
endif %}
30
<span>
{{
item.label
|
trans
(
domain
=
translation_domain
)
}}
</span>
31
{%
if
item.children
|
default
([])
is
not
empty
%}
<i class="fa fa-angle\
32
-left pull-right"></i>
{%
endif
%}
33
</a>
34
{%
endif
%}
35
{%
endmacro
%}
36
37
{%
import
_self
as
helper
%}
38
39
{%
block
main_menu_before
%}{%
endblock
%}
40
41
<ul class="sidebar-menu">
42
{%
block
main_menu
%}
43
{%
for
item
in
easyadmin_config
(
'design.menu'
)
%}
44
{%
if
is_granted
(
item.role
)
%}
45
<li class="
{{
item.type
==
'divider'
?
'header'
}}
{{
item.child
\
46
ren
is
not
empty
?
'treeview'
}}
{{
app.request.query.get
(
'menuIndex'
)|
default
(-
\
47
1
)
==
loop
.index0
?
'active'
}}
{{
app.request.query.get
(
'submenuIndex'
)|
default
\
48
(-
1
)
!
=
-
1
?
'submenu-active'
}}
">
49
50
{{
helper.render_menu_item
(
item
,
'app'
)
}}
51
52
{%
if
item.children
|
default
([])
is
not
empty
%}
53
<ul class="treeview-menu">
54
{%
for
subitem
in
item.children
%}
55
<li class="
{{
subitem.type
==
'divider'
?
'heade\
56
r'
}}
{{
app.request.query.get
(
'menuIndex'
)|
default
(-
1
)
==
loop
.parent.loop.inde
\
57
x0
and
app.request.query.get
(
'submenuIndex'
)|
default
(-
1
)
==
loop
.index0
?
'activ\
58
e'
}}
">
59
{{
helper.render_menu_item
(
subitem
,
_entity_
\
60
config.translation_domain
|
default
(
'messages'
))
}}
61
</li>
62
{%
endfor
%}
63
</ul>
64
{%
endif
%}
65
</li>
66
{%
endif
%}
67
{%
endfor
%}
68
{%
endblock
main_menu
%}
69
</ul>
70
71
{%
block
main_menu_after
%}{%
endblock
%}
Try logging in now as test1 and you will see that the menu and entities should be access controlled.
Adding Roles to EasyAdmin Actions
We have seen that easyadmin actions is controlled by the yml files, ie something like:
1
# app/config/easyadmin/userlog.yml
2
...
3
show:
4
actions: ['list', '-edit', '-delete']
5
list:
6
actions: ['show', '-edit', '-delete']
7
...
What if we want the actions to be “role” aware? If you look at the easyadmin twig files, you will see that it calls a Twig function “getActionsForItem” to get the actions prior to render. This gives us a chance to change the function logic by extending the class.
1
#
src
/
AppBundle
/
Twig
/
Extension
/
EasyAdminTwigExtension
.php
2
3
namespace
AppBundle
\
Twig
\
Extension
;
4
5
use
JavierEguiluz
\
Bundle
\
EasyAdminBundle
\
Configuration
\
ConfigManager
;
6
use
Symfony
\
Component
\
PropertyAccess
\
PropertyAccessor
;
7
use
Symfony
\
Component
\
Security
\
Core
\
Authorization
\
AuthorizationChecker
;
8
9
/**
10
* Class EasyAdminTwigExtension
11
* @package AppBundle\Twig\Extension
12
*/
13
class
EasyAdminTwigExtension
extends
\
JavierEguiluz
\
Bundle
\
EasyAdminBundle
\
Twig
\\
14
EasyAdminTwigExtension
15
{
16
private
$
checker
;
17
18
public
function
__construct
(
ConfigManager
$
configManager
,
PropertyAccessor
$\
19
propertyAccessor
,
$
debug
=
false
,
AuthorizationChecker
$
checker
)
20
{
21
parent
::
__construct
(
$
configManager
,
$
propertyAccessor
,
$
debug
);
22
$
this
->
checker
=
$
checker
;
23
}
24
25
/**
26
* Overrides parent function
27
*
28
* @param string $view
29
* @param string $entityName
30
*
31
* @return array
32
*/
33
public
function
getActionsForItem
($
view
,
$
entityName
)
34
{
35
$
entityConfig
=
$
this
->
getEntityConfiguration
(
$
entityName
);
36
$
disabledActions
=
$
entityConfig
[
'disabled_actions'
]
;
37
$
viewActions
=
$
entityConfig
[
$view
][
'actions'
]
;
38
39
$
actionsExcludedForItems
=
array
(
40
'list'
=>
array
(
'new'
,
'search'
)
,
41
'edit'
=>
array
()
,
42
'new'
=>
array
()
,
43
'show'
=>
array
()
,
44
);
45
$
excludedActions
=
$
actionsExcludedForItems
[
$view
]
;
46
47
//
hid
these
buttons
if
easyadmin
says
so
48
$
actions
=
[
'edit'
,
'form'
,
'delete'
,
'list'
,
'show'
]
;
49
foreach
(
$
actions
as
$
action
)
{
50
if
(
isset
(
$
entityConfig
[
$action
][
'role'
]
)
&&
!
$
this
->
checker
->
isGran
\
51
ted
(
$
entityConfig
[
$action
][
'role'
]
))
{
52
array_push
(
$
excludedActions
,
$
action
);
53
}
54
}
55
56
return
array_filter
($
viewActions
,
function
($
action
)
use
($
excludedActio
\
57
ns
,
$
disabledActions
)
{
58
return
!
in_array
(
$
action
[
'name'
]
,
$
excludedActions
)
&&
!
in_array
(
$
ac
\
59
tion
[
'name'
]
,
$
disabledActions
);
60
}
);
61
}
62
}
And we have to remember to call our new twig class in services.yml
1
# src/AppBundle/Resources/config/services.yml
2
...
3
app.twig.extension:
4
class: AppBundle\Twig\Extension\EasyAdminTwigExtension
5
arguments:
6
- "@easyadmin.config.manager"
7
- "@property_accessor"
8
- "%kernel.debug%"
9
- "@security.authorization_checker"
10
tags:
11
- { name: twig.extension }
One thing to remember though is that we have to load our AppBundle after EasyAdminbundle so that our app.twig.extension can override the easyadmin.twig.extension service of EasyAdminBundle
1
#
app/AppKernel.php
2
...
3
$
bundles
= [
4
new Symfony\Bundle\FrameworkBundle\FrameworkBundle(),
5
new Symfony\Bundle\SecurityBundle\SecurityBundle(),
6
new Symfony\Bundle\TwigBundle\TwigBundle(),
7
new Symfony\Bundle\MonologBundle\MonologBundle(),
8
new Symfony\Bundle\SwiftmailerBundle\SwiftmailerBundle(),
9
new Doctrine\Bundle\DoctrineBundle\DoctrineBundle(),
10
new Sensio\Bundle\FrameworkExtraBundle\SensioFrameworkExtraBundle(),
11
new Vich\UploaderBundle\VichUploaderBundle(),
12
// init my fosuser
13
new FOS\UserBundle\FOSUserBundle(),
14
new Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle(),
15
new JavierEguiluz\Bundle\EasyAdminBundle\EasyAdminBundle(),
16
new Bpeh\NestablePageBundle\BpehNestablePageBundle(),
17
new Ivory\CKEditorBundle\IvoryCKEditorBundle(),
18
new AppBundle\AppBundle(),
19
new AppBundle\User(),
20
new AppBundle\Page(),
21
...
I have disabled the “edit” action for all users, so the edit button will not show even if the user is himself. For the sake of simplicity, let us change the layout header link to use edit action instead.
1
# app/Resources/EasyAdminBundle/views/default/layout.html.twig
2
...
3
<a
href=
"
{{
path
(
'easyadmin'
)
}}
/?entity=User&action=edit&id=
{{
app.user.id
}}
"
>
\
4
{{
app.user.username
|
default
(
'user.unnamed'
|
trans
(
domain
=
'EasyAdminBundle'
))
}
\
5
}</
a
>
6
...
Cleaning up
We are close to the end of the chapter. Let us clean up all our code using php-cs-fixer (Still remember this?)
1
-> vendor/friendsofphp/php-cs-fixer/php-cs-fixer fix src/
2
-> vendor/friendsofphp/php-cs-fixer/php-cs-fixer fix src/
3
# finally optimising composer
4
-> ./scripts/optimize_composer
Update BDD (Optional)
We have updated some business rules. Users can now see and do what they are allowed in the admin area based on their role in the easyadmin yaml config files. Its time to ensure we update our tests to reflect these changes.
Summary
In this chapter, we have cleaned up config.yml and provided a custom solution (Using compiler pass and event listeners) to make EasyAdmin support user permissions in the admin area. It was a huge effort but not yet a full solution. However, it should make life easy for people who wants to configure admin permissions easily.
Exercises
- Think of another way to make EasyAdmin support user permissions.
- Write your test and make sure everything passes (Optional)
- Can you implement autowiring in services.yml? What are the pros and cons of using autowiring?
References
Final Chapter: Conclusion
Congratulations for perservering for so long… It’s been a long journey. In the previous chapters, we have created a simple CMS using a modular approach. The CMS is really simple but is secure, supports user logging and internalisation. While going through the exercises, we have explored possibilities to build different parts of the CMS bit by bit.
Now, you have all the basic knowledge and foundation to create more complex applications with Symfony.
So, what’s next from here? Ready for more adventures?
Here are some suggestions:
- Start building something with Symfony fullstack or its components.
- I am sure you will find bugs and typos along the way. Create pull requests for SongBird in git.
- Improve on the NestablePageBundle to reduce the amount of work required to integrate with EasyAdminBundle.
- Create API for 3rd party services to connect to.
- Add Ecommerce capability to the CMS by adding a payment module.
- Improve on look and feel. The frontend looks too plain.
- Try Implement ACL for users.
- Investigate the best practices to deploy your application to a reliable server and configure the production settings.
- What if your application becomes popular and you are getting a lot of traffic? What are the options to optimise your application?
- What about configuring cron jobs to clean up user logging table?
- How about packing your application up into an installable bundle? That way, you can distribute your application easily.
Good luck on your next Symfony Journey!