Controllers
Symfony follows the philosophy of “thin controllers and fat models”.
This means that controllers should hold just the thin layer of glue-code
needed to coordinate the different parts of the application.
As a rule of thumb, you should follow the 5-10-20 rule, where
controllers should only define 5 variables or less, contain 10 actions
or less and include 20 lines of code or less in each action. This isn't
an exact science, but it should help you realize when code should be
refactored out of the controller and into a service.
Source:
http://symfony.com/doc/current/best_practices/controllers.html
A unit test is a test against a single PHP class, also called a unit. If
you want to test the overall behavior of your application, then use
Functional Tests. Because controllers glue together the different parts
of the applications they are not usually tested with Unit Tests, instead
they are tested with Functional Tests.
Organizing Your Business Logic
Useful info:
http://symfony.com/doc/current/best_practices/business-logic.html
It is very easy to start writing business logic into controller, but
this way we will end up with a large function, difficult to read which
will break
separation of concerns principle https://en.wikipedia.org/wiki/Separation_of_concerns and makes it impossible to test only the business logic.
In order to better organize the code, additional classes should be
created. Let's take as an example a class that handles file uploading.
Basically it receives the $file uploaded in form and the path where to
save the file with a unique name in order to avoid collisions with
previous uploaded files. The function returns the name of the file saved
on the disk to be persisted in database.
namespace YourBundle\Utils;
Class FileUpload
{
public function upload(\Symfony\Component\HttpFoundation\File\UploadedFile $file, $uploadDir)
{
// Generate a unique name for the file before saving it
$fileName = md5(uniqid()).$file->guessExtension();
// Move the file to the directory where uploads are stored
$file->move($uploadDir, $fileName);
// Update the 'Attachement' property to store the file name
// instead of its contents
return $fileName;
}
}
The file uploading method is based on this article from documentation:
http://symfony.com/doc/current/cookbook/controller/upload_file.html
In your controller, the FileUpload class will be used like this:
use YourBundle\Utils\FileUpload;
....
public function createAction(Request $request)
{
....
if ($form->isValid()) {
// $file stores the uploaded file
// @var Symfony\Component\HttpFoundation\File\UploadedFile $file
$file = $product->getBrochure();
// Obtain from parameters the directory where brochures are stored
$uploadDir = $this->container->getParameter('kernel.root_dir').'/../web/uploads/brochures';
//use FileUpload class
$uploader= new FileUpload();
$fileName = $uploader->upload($file, $uploadDir);
// Update the 'brochure' property to store the file name
// instead of its contents
$product->setBrochure($fileName);
// ... persist the $product variable or any other work
return $this->redirect($this->generateUrl('app_product_list'));
}
.....
}
OK, so now we have a testable function upload() which can be tested
independently from the rest of the controller, we just need a mock
object of type 'Symfony\Component\HttpFoundation\File\UploadedFile' and a
path.
Maintaining the code
Parameters
We are very happy with the result and we started using this class in all
controllers, just that at some point we receive a request to change
the path where to save the file uploaded. This means we need to search
in all controllers and change this line:
$uploadDir = $this->container->getParameter('kernel.root_dir').'/../web/uploads/brochures';
In order to prevent this in future we can declare a parameter in a local parameters.yml which is imported in the global one:
parameters:
yourbundle.upload_dir: '%kernel.root_dir%/../web/uploads/attachments/YourBundle/Upload'
and use it in all controllers:
$uploadDir = $this->container->getParameter('yourbundle.upload_dir');
Dependencies
Now we are happy again and think nothing bad can happen to our code :).
But then again there is a request to save the files to a second path, a
Back Up folder let's say. We could do this by adding a third parameter
in the upload() function:
public function upload(\Symfony\Component\HttpFoundation\File\UploadedFile $file, $uploadDir, $uploadDirBackUp)
And we have to modify in all controllers where we are using this
function. We will declare this BackUp dir as a parameter also and use it
like this:
$uploadDirBackUp = $this->container->getParameter('yourbundle.upload_dir_backup');
$fileName = $uploader->upload($file, $uploadDir, $uploadDirBackUp);
So we realize that injecting dependencies directly in our function is
not a solution. Usually the recommended way to inject dependencies is in
constructor definition or by using setters. Let's inject dependencies
in constructor:
namespace YourBundle\Utils;
Class FileUpload
{
$uploadDir;
$uploadDirBackUp;
public function __construct($uploadDir, $uploadDirBackUp)
{
$this->uploadDir = $uploadDir;
$this->uploadDirBackUp = $uploadDirBackUp;
}
public function upload(\Symfony\Component\HttpFoundation\File\UploadedFile $file)
{
// Generate a unique name for the file before saving it
$fileName = md5(uniqid()).$file->guessExtension();
// Move the file to the directory where uploads are stored
$file->move($this->uploadDir, $fileName);
....
}
}
Even if the dependencies are injected in constructor, we would still have to modify all the controllers:
use YourBundle\Utils\FileUpload;
....
public function createAction(Request $request)
{
....
// Move the file to the directory where brochures are stored
$uploadDir = $this->container->getParameter('yourbundle.upload_dir');
$uploadDirBackUp = $this->container->getParameter('yourbundle.upload_dir_backup');
$uploader= new FileUpload($uploadDir,$uploadDirBackUp);
$fileName = $uploader->upload($file);
.....
}
The solution? Creating a service!
What is a Service?
Put simply, a Service is any PHP object that performs some sort of
“global” task. It's a purposefully-generic name used in computer science
to describe an object that's created for a specific purpose (e.g.
delivering emails). Each service is used throughout your application
whenever you need the specific functionality it provides. You don't have
to do anything special to make a service: simply write a PHP class with
some code that accomplishes a specific task. Congratulations, you've
just created a service!
As a rule, a PHP object is a service if it is used globally in your
application. A single Mailer service is used globally to send email
messages whereas the many Message objects that it delivers are not
services. Similarly, a Product object is not a service, but an object
that persists Product objects to a database is a service.
A
Service Container (or d
ependency injection container) is simply a PHP object that manages the instantiation of services (i.e. objects).
Source:
http://symfony.com/doc/current/book/service_container.html
Create the service
In your bundle create a file called services.yml under Resources\config
services:
yourbundle_file_upload_service:
class: YourBundle\Utils\FileUpload
arguments: ['%yourbundle.upload_dir%','%yourbundle.upload_dir_backup%' ]
Now the class will not be directly instantiated, instead we will access it from the container. In controller:
$fileUpload = $this->get('yourbundle_file_upload_service');
$fileName = $fileUpload->upload($file);
What we've accoplished? Now each time we want to add or modify a
dependency we need to edit just in two places: in the services.yml file
and in the
contruct() function.
Note: make sure that your bundle is not configured to
expect a XML (services.xml) file in
YourBundle\DependencyInjection\YourExtension.php:
$loader = new Loader\YamlFileLoader($container, new FileLocator(DIR__.'/../Resources/config'));
$loader->load('services.yml');